diff --git a/README.md b/README.md index 663a0686..85586b71 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ SDK-compat coverage across AWS, Azure, and GCP: | Storage | S3 | Blob Storage | GCS | | Compute | EC2 (+ VPC, EBS, Snapshots, AMIs, Spot, Launch Templates, Auto Scaling) | Virtual Machines (+ Disks, Snapshots, Images, SSH keys) | Compute Engine (+ Disks, Snapshots, Images) | | NoSQL DB | DynamoDB | Cosmos DB | Firestore | -| Relational DB | RDS + Aurora (incl. Neptune & DocumentDB engines), Redshift | SQL Database, PostgreSQL Flexible Server, MySQL Flexible Server | Cloud SQL, AlloyDB | +| Relational DB | RDS + Aurora (incl. Neptune & DocumentDB engines), Redshift | SQL Database, PostgreSQL Flexible Server, MySQL Flexible Server, Cosmos DB for PostgreSQL (Citus) | Cloud SQL, AlloyDB | | Wide-column NoSQL | Keyspaces (Cassandra) | Managed Instance for Apache Cassandra | Bigtable | | In-memory / Redis | ElastiCache, MemoryDB | Cache for Redis | Memorystore | | Kubernetes | EKS (control plane + data plane) | AKS (control plane + data plane) | GKE (control plane + data plane) | @@ -139,9 +139,9 @@ SDK-compat coverage across AWS, Azure, and GCP: The Kubernetes story is two layers, both shipped: - **Control plane** (EKS / AKS / GKE) — cluster, node-pool, addon / Fargate / maintenance-config lifecycle via the real cloud SDKs. -- **Data plane** (in-memory Kubernetes API) — Namespace, Pod, Service, ConfigMap, Secret, ServiceAccount, Deployment, Endpoints. Supports CRUD + JSON-merge Patch + Watch streaming, so real `client-go` `Informer`/`Reflector` machinery works against a cloudemu-emulated cluster. Kubeconfigs returned by the control plane point at the in-memory data plane — `kubectl apply -f deployment.yaml` followed by `kubectl get pods` round-trips end-to-end. +- **Data plane** (in-memory Kubernetes API) — core, apps, batch, networking, rbac, storage, autoscaling, policy, discovery, **apiextensions** (CRDs) and **admissionregistration** kinds. Supports CRUD, all patch types + **server-side apply** (field ownership + conflicts), `?dryRun=All`, finalizers, `limit`/`continue` pagination, watch streaming with `resourceVersion` resume + BOOKMARK — so real `client-go` `Informer`/`Reflector` machinery works against a cloudemu-emulated cluster. Kubeconfigs returned by the control plane point at the in-memory data plane — `kubectl apply -f deployment.yaml` followed by `kubectl get pods` round-trips end-to-end. -What's intentionally out of scope: real controllers (Deployment ↛ ReplicaSet ↛ Pod), scheduler (Pods stay Pending), RBAC, PV/PVC, StatefulSet/DaemonSet/Job/CronJob, Ingress. +Emulation model: there is no scheduler or kubelet, so controllers converge **synchronously** — a Deployment interposes a ReplicaSet and materializes Pods straight to Running (a Job's straight to Succeeded), and Services get Endpoints, on every write. On top of the raw object store it also serves **CRDs** (dynamic servable kinds), **`metrics.k8s.io`** + **HPA** actuation, object-count **ResourceQuota** / **LimitRange** / **PDB-gated eviction** enforcement, **RBAC** SubjectAccessReview + **NetworkPolicy** evaluation, and **opt-in admission webhooks**. See [docs/services.md](docs/services.md) §18 for the authoritative capability list. Full per-service operation list: [docs/services.md](docs/services.md). Per-handler protocol details and limitations: [docs/sdk-server.md](docs/sdk-server.md). diff --git a/cloudemu_test.go b/cloudemu_test.go index 01a46d86..d2e70377 100644 --- a/cloudemu_test.go +++ b/cloudemu_test.go @@ -7097,13 +7097,10 @@ func TestVolumeLifecycleAWS(t *testing.T) { t.Fatal(err) } - vols, err = p.EC2.DescribeVolumes(ctx, []string{vol.ID}) - if err != nil { - t.Fatal(err) - } - - if len(vols) != 0 { - t.Errorf("expected 0 volumes after delete, got %d", len(vols)) + // Describing the deleted volume by ID now yields NotFound (issue #319, + // theme C: InvalidVolume.NotFound), not an empty success. + if _, err = p.EC2.DescribeVolumes(ctx, []string{vol.ID}); err == nil { + t.Error("expected NotFound describing a deleted volume, got nil") } } diff --git a/docs/sdk-server.md b/docs/sdk-server.md index 2b4bc325..3371c1fe 100644 --- a/docs/sdk-server.md +++ b/docs/sdk-server.md @@ -197,10 +197,11 @@ All handlers speak ARM JSON over HTTPS unless noted. | **SQL Database** | `Microsoft.Sql/servers[/databases]` — servers and databases, full CRUD lifecycle | | **Managed Cassandra** | `Microsoft.DocumentDB/cassandraClusters[/dataCenters]` — clusters (CreateOrUpdate, Get, ListByResourceGroup, ListBySubscription, Update, Delete, deallocate, start, invokeCommand, status) and datacenters (CreateOrUpdate, Get, List, Update, Delete). Real `armcosmos` `CassandraClusters`/`CassandraDataCenters` clients round-trip end-to-end, including the LRO pollers. | | **PostgreSQL Flexible Server** | `Microsoft.DBforPostgreSQL/flexibleServers` — full CRUD lifecycle | +| **Cosmos DB for PostgreSQL** | `Microsoft.DBforPostgreSQL/serverGroupsv2` — clusters (CreateOrUpdate, Get, ListByResourceGroup, ListBySubscription, Update, Delete, restart, start, stop, promote, checkNameAvailability), firewall rules, roles, derived servers/nodes, configurations (cluster/coordinator/node reads + updates), and private endpoint connections/links. Real `armcosmosforpostgresql` clients round-trip end-to-end, including the LRO pollers. | | **MySQL Flexible Server** | `Microsoft.DBforMySQL/flexibleServers` — full CRUD lifecycle | | **AKS** | `Microsoft.ContainerService/managedClusters` — ManagedClusters (CreateOrUpdate, Get, UpdateTags, Delete, List/ListByResourceGroup), AgentPools (CreateOrUpdate, Get, Delete, List), MaintenanceConfigurations (CreateOrUpdate, Get, Delete, List), ListClusterAdmin/User/MonitoringUser Credentials, RotateClusterCertificates. Stub kubeconfig only — data plane deferred to Wave 2. | | **IAM (armauthorization)** | `Microsoft.Authorization` — RoleDefinitions (CreateOrUpdate, Get, List, Delete) and RoleAssignments (Create, Get, ListForScope, Delete) at any scope (subscription, resource group, resource, management group). Real `armauthorization` SDK clients round-trip end-to-end. Microsoft Graph (users/groups) is out of scope — deferred to a future handler. | -| **Resource Graph** | `Microsoft.ResourceGraph` — `POST /providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01` with a KQL-shaped query over the cross-service inventory; supports `subscriptions[]` scoping and `$top`/`$skipToken` pagination | +| **Resource Graph** | `Microsoft.ResourceGraph` — `POST /providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01` with a KQL-shaped query over the cross-service inventory; supports `subscriptions[]` scoping and `$top`/`$skipToken` pagination. Rows carry the fixed columns (`id` [ARM-shaped], `name`, `type`, `location`, `resourceGroup`, `subscriptionId`, `tags`) plus resource-shape columns emitted when present — `sku.name`, `properties`, `managedBy`, `kind`, `zones` — so SKU/tier/size-sensitive consumers (e.g. a discovery + cost engine) can read a VM's size, a managed disk's tier/`diskSizeGB`/owning VM, or a flexible server's compute SKU. `project`/`summarize`/`join` are tolerated but ignored (the full row is always returned). | | **Databricks (ARM control plane)** | `Microsoft.Databricks/workspaces` — CreateOrUpdate, Get, Delete, UpdateTags, List / ListByResourceGroup. Real `armdatabricks` SDK clients round-trip end-to-end. | | **Databricks (workspace data plane)** *(`databricks-sdk-go`, `/api/2.x`)* | Point the real `WorkspaceClient` at `Config.Host`. Clusters (create/edit/start/restart/resize/pin/unpin/delete + list-node-types / spark-versions / zones), instance pools, jobs + runs (submit / run-now / get / list / cancel / cancel-all / repair / output / delete), cluster policies, libraries (install / uninstall / status), and object permissions. Self-contained families: secrets (scopes / secrets / ACLs), tokens, git credentials, repos, DBFS (incl. block upload), workspace notebooks/directories, SQL warehouses, pipelines, serving endpoints, SCIM identity (users / groups / service principals), and Unity Catalog (catalogs / schemas / tables + metastores / external locations / storage credentials / volumes). Also serves `GET /.well-known/databricks-config` so the SDK's host-metadata resolution succeeds (workspace-host stub) instead of logging a warning. | @@ -297,6 +298,7 @@ Each handler uses a different signal so dispatch is unambiguous within a provide | Azure SQL | ARM provider `Microsoft.Sql` | | Azure Managed Cassandra | ARM provider `Microsoft.DocumentDB/cassandraClusters` | | Azure PostgreSQL Flexible | ARM provider `Microsoft.DBforPostgreSQL/flexibleServers` | +| Azure Cosmos DB for PostgreSQL | ARM provider `Microsoft.DBforPostgreSQL/serverGroupsv2` | | Azure MySQL Flexible | ARM provider `Microsoft.DBforMySQL/flexibleServers` | | Azure AKS | ARM provider `Microsoft.ContainerService/managedClusters` | | Azure Databricks (ARM) | ARM provider `Microsoft.Databricks/workspaces` | @@ -328,7 +330,7 @@ Registration order matters when handlers share a path prefix — `awsserver.New` Kubernetes ships as **two cooperating handlers**: per-provider control planes (EKS / AKS / GKE — clusters + node pools + addons / Fargate / maintenance configs) and a shared in-memory **data plane** registered under `/k8s/{cluster-uid}/`. The control plane mints a UID on every cluster Create and embeds it in the kubeconfig (or `Cluster.Endpoint` for GKE) along with a CA that certifies the data-plane serving cert, so `client-go` and `kubectl` connect over **validated TLS**. The data plane behaves like a tiny always-converged cluster (minikube-like): a synchronous reconcile engine runs on every write, so Deployments/ReplicaSets/StatefulSets/DaemonSets materialize **Running** Pods, Services get populated Endpoints, PVCs bind, and Jobs complete — all immediately and deterministically (no controller goroutines). Core, apps, batch, networking, rbac, storage, autoscaling, discovery, and policy groups are served, with `/scale` and `/status` subresources, label/field selectors, and `?watch=true` streaming (selector-filtered) so real `Informer` / `Reflector` machinery works. Data-plane lists are unpaginated (`limit`/`continue` are ignored — every list returns the full set). -Non-goals are deliberate emulation boundaries: no kubelet-backed Pod subresources (`/log`, `/exec`, `/attach`, `/portforward`), no real scheduling (single synthetic node, no affinity/taints), no admission/quota/policy **enforcement** (ResourceQuota, LimitRange, NetworkPolicy, RBAC, PDB are stored but not enforced), HPA does not autoscale, CronJob does not fire on schedule, and rollouts converge instantly with no surge pacing or revision history. See `docs/services.md` §18 for the full resource list. +The data plane now covers CustomResourceDefinitions (dynamic servable kinds), server-side apply with `managedFields` field ownership + conflict detection, `?dryRun=All`, finalizer-gated deletion, `?limit=&continue=` pagination, synthetic `pods/log` + PDB-gated `pods/eviction`, `metrics.k8s.io` (`kubectl top`) + HPA actuation, object-count ResourceQuota / LimitRange / PDB enforcement, RBAC SubjectAccessReview + NetworkPolicy evaluation, opt-in admission webhooks, watch `resourceVersion` resume + BOOKMARK, and a deterministic injectable clock. Remaining emulation boundaries are deliberate simplifications: no real kubelet (synthetic logs; `exec`/`attach`/`portforward` return a typed 501), no scheduling beyond the single synthetic node (DaemonSet `nodeSelector` honored; no affinity/taints), admission webhooks call out only when explicitly enabled, RBAC/NetworkPolicy are queryable rather than request-time-enforced, CronJob fires via `TickCronJobs` (no wall clock), and rollouts converge instantly. See `docs/services.md` §18 for the full resource list. Two provider-specific services also ship as full SDK-compat handlers. **AWS Bedrock** covers the `bedrock` control plane (foundation models, customization jobs, custom models, guardrails with policy configs + versions, provisioned throughput, invocation logging, resource tagging, model import/copy/evaluation jobs, inference profiles, prompt routers, marketplace model endpoints, foundation-model agreements, and automated-reasoning policies) and the `bedrock-runtime` data plane (InvokeModel with family-aware response envelopes, Converse, streaming ConverseStream / InvokeModelWithResponseStream over `vnd.amazon.eventstream`, CountTokens, ApplyGuardrail, and async invoke). A companion **AWS Bedrock Agent** handler covers the `bedrock-agent` control plane (agents, knowledge bases, data sources, flows, prompts) and the `bedrock-agent-runtime` data plane (InvokeAgent streaming, Retrieve, RetrieveAndGenerate); its runtime handler registers before the control plane and matches only POST so the two never collide on the shared `/agents` and `/knowledgebases` roots. `bedrock-agent` coverage is intentionally scoped to this core resource lifecycle and runtime data plane — agent versioning/aliases beyond basic create, action groups, and agent collaborators are out of scope for this iteration. **Azure Databricks** covers the `armdatabricks` ARM workspace resource plus the `databricks-sdk-go` workspace data plane — clusters, instance pools, jobs and runs, cluster policies, libraries, permissions, secrets, tokens, git credentials, repos, DBFS, workspace notebooks/directories, SQL warehouses, pipelines, serving endpoints, SCIM identity, and Unity Catalog. @@ -364,5 +366,6 @@ The `Handler` interface is the only contract — no registration is needed in co - **GCS direct-media downloads** assume path-style URLs. - **DynamoDB / Cosmos / Firestore filters and queries** support common patterns but are not full DSL parsers. - **Pagination tokens** are honored where present in the SDK contract; some list operations short-circuit to a single page. +- **Resource Graph `resourceGroup`.** Rows expose `resourceGroup` derived from the resource's ARM id. Where a mock doesn't model a per-resource resource group, the id (and thus `resourceGroup`) falls back to `default`, so all such resources share one resource group — fine for SKU/tier/size-sensitive discovery and cost tests, but consumers that key on distinct resource groups should be aware. Event Hubs (`microsoft.eventhub/namespaces`) is not yet modeled, so its `sku.tier` isn't surfaced. When a client hits an unsupported operation, the server responds with the provider's native error code so failures are easy to diagnose. diff --git a/docs/services.md b/docs/services.md index 5125ce0e..06a7532f 100644 --- a/docs/services.md +++ b/docs/services.md @@ -10,7 +10,8 @@ This document lists every service and operation available in CloudEmu across all | 2 | Compute | `ec2` | `virtualmachines` | `gce` | | 3 | Database | `dynamodb` | `cosmosdb` | `firestore` | | 4 | Serverless | `lambda` | `functions` | `cloudfunctions` | -| 5 | Networking | `vpc` | `vnet` | `gcpvpc` | +| 5 | Networking | `vpc` (+ AWS-specific: Transit Gateway, VPN, DHCP options, prefix lists, egress-only IGW, endpoint services, Client VPN, Traffic Mirroring, Network Insights, VPC Block Public Access) | `vnet` | `gcpvpc` | +| 5a | Network Firewall | `network-firewall` | — | — | | 6 | Monitoring | `cloudwatch` | `azuremonitor` | `cloudmonitoring` | | 7 | IAM | `awsiam` | `azureiam` | `gcpiam` | | 8 | DNS | `route53` | `azuredns` | `clouddns` | @@ -26,6 +27,7 @@ This document lists every service and operation available in CloudEmu across all | 17a | In-memory Database (Redis/Valkey) | `memorydb` | — | — | | 17b | Wide-column (Cassandra) | `keyspaces` | `managedcassandra` | — | | 17c | Wide-column (Bigtable) | — | — | `bigtable` | +| 17d | Distributed PostgreSQL (Citus) | — | `cosmospostgresql` | — | | 18 | Kubernetes | `eks` + shared `services/kubernetes/` | `aks` + shared `services/kubernetes/` | `gke` + shared `services/kubernetes/` | | 19 | Resource Discovery | `resourceexplorer2` + `resourcegroupstaggingapi` | `resourcegraph` | `cloudasset` | | 20 | Generative AI | `bedrock` (+ `bedrock-runtime`), `bedrock-agent` (+ `bedrock-agent-runtime`) | — | — | @@ -532,6 +534,36 @@ DNS hostnames off. **Total: 47 operations** +### AWS-specific networking (optional capabilities) + +AWS models several networking resources that don't map cleanly across clouds. +These are **AWS-only optional capability interfaces** (discovered by type +assertion, like `NetworkInterfaces`/`VPCAttributes`) implemented by +`providers/aws/vpc` and served by the EC2 handler — no Azure/GCP stubs. + +| Capability | Operations | +|-----------|-----------| +| Transit Gateway | CreateTransitGateway, DeleteTransitGateway, DescribeTransitGateways; VPC attachments (Create/Delete/Describe); route tables (Create/Delete/Describe); routes (Create/Delete/Search); route-table Associate + Enable/DisableRouteTablePropagation | +| VPN | CustomerGateway (Create/Delete/Describe); VpnGateway (Create/Delete/Describe/Attach/Detach); VpnConnection (Create/Delete/Describe/ModifyVpnConnection); VpnConnectionRoute (Create/Delete) | +| DHCP option sets | Create, Delete, Describe, Associate | +| Managed prefix lists | Create, Delete, Describe, GetEntries, Modify | +| Egress-only internet gateways | Create, Delete, Describe | +| VPC endpoint services (PrivateLink) | Create, Delete, Describe; ModifyPermissions, DescribePermissions | +| Client VPN | CreateEndpoint, DeleteEndpoint, DescribeEndpoints, Associate/DisassociateTargetNetwork, DescribeTargetNetworks; Authorize/RevokeIngress, DescribeAuthorizationRules; Route (Create/Delete/Describe) | +| Traffic Mirroring | Target (Create/Delete/Describe); Filter (Create/Delete/Describe) + ModifyFilterNetworkServices; FilterRule (Create/Modify/Delete/Describe); Session (Create/Modify/Delete/Describe) | +| Network Insights — Reachability Analyzer | Path (Create/Delete/Describe); Analysis (Start/Delete/Describe) | +| Network Insights — Network Access Analyzer | AccessScope (Create/Delete/Describe) + GetContent; AccessScopeAnalysis (Start/Delete/Describe) + GetAnalysisFindings | +| VPC Block Public Access | Options (Describe/Modify); Exclusion (Create/Modify/Delete/Describe) | +| IPAM (IP Address Manager) — full | Ipam/Scope/Pool CRUD+Modify; Cidr Provision/Deprovision/Get; Allocation Allocate/Release/Get/Modify; ResourceCidrs (Get/Modify) + AddressHistory; ResourceDiscovery CRUD + Associate/Disassociate + Discovered Accounts/ResourceCidrs/PublicAddresses; BYOASN (Provision/Deprovision/Associate/Disassociate/Describe); BYOIP (Move/Provision/Deprovision/Describe/Advertise/Withdraw); PrefixListResolver + Targets + Versions/Rules/Entries; ExternalResourceVerificationToken (Create/Delete/Describe); Policy (Create/Delete/Describe/Enable/Disable/GetEnabled/AllocationRules/OrgTargets) + OrganizationAdminAccount (Enable/Disable) | + +**AWS-specific total: 162 operations** + +IPAM is fully covered (~69 operations). Cross-account/organization and live-network features (Resource Discovery, discovered accounts/resources/public addresses, BYOASN/BYOIP, policies, org-admin) are modeled against the emulator's own single-account state: discovered resources are derived from the stored VPCs/subnets/EIPs, and organization targets resolve to the configured account. + +### IPAM metrics (`AWS/IPAM` CloudWatch namespace) + +IPAM publishes derived metrics through the CloudWatch service (ListMetrics / GetMetricStatistics): `TotalActiveIpCount`; pool `PercentAllocated`/`PercentAssigned`/`PercentAvailable`/`Compliant`/`NoncompliantResourceCidrs`; scope `Managed`/`Unmanaged`/`Overlapping`/`Compliant`/`NoncompliantResourceCidrs`; public-IP insight counts; and resource utilization `VpcIPUsage`/`SubnetIPUsage`. Values are computed live from IPAM + VPC/subnet/EIP state. + --- ## 6. Monitoring @@ -1222,6 +1254,57 @@ counts are bounded; clone-on-read on every path. --- +## 11e. Cosmos DB for PostgreSQL (Azure) + +**Driver interface:** `services/cosmospostgresql/driver/driver.go` +**Azure:** Cosmos DB for PostgreSQL (Citus) — `Microsoft.DBforPostgreSQL/serverGroupsv2` + +Real `armcosmosforpostgresql` clients configured with a custom endpoint hit the +ARM handler (`server/azure/cosmospostgresql`) the same way they hit +management.azure.com. Create/update RPCs return the resource inline with a +terminal `provisioningState`; the cluster start/stop/restart/promote actions +reply `202` + `Location` and the poller reads a terminal status from the +`operationStatuses` URL. + +### Clusters (server groups) + +| Operation | Signature | +|-----------|-----------| +| `CreateOrUpdateCluster` | `(ctx, CreateClusterConfig) (*Cluster, error)` | +| `GetCluster` / `ListClustersByResourceGroup` / `ListClustersBySubscription` | cluster reads | +| `UpdateCluster` | `(ctx, rg, name, ClusterPatch) (*Cluster, error)` (PATCH) | +| `DeleteCluster` | `(ctx, rg, name) error` | +| `RestartCluster` / `StartCluster` / `StopCluster` | lifecycle actions (LRO) | +| `PromoteReadReplica` | `(ctx, rg, name) error` — detach a replica | +| `CheckNameAvailability` | `(ctx, name, type) (*NameAvailability, error)` | + +### Firewall Rules & Roles + +| Operation | Signature | +|-----------|-----------| +| `CreateOrUpdateFirewallRule` / `GetFirewallRule` / `ListFirewallRules` / `DeleteFirewallRule` | IP allow-list CRUD | +| `CreateRole` / `GetRole` / `ListRoles` / `DeleteRole` | Postgres role CRUD | + +### Servers (nodes), Configurations & Private Endpoints + +| Operation | Signature | +|-----------|-----------| +| `GetServer` / `ListServers` | read-only derived nodes (coordinator + workers) | +| `ListConfigurations` / `GetConfiguration` | cluster-wide server parameters (per-role values) | +| `GetCoordinatorConfiguration` / `GetNodeConfiguration` / `ListServerConfigurations` | server-scoped parameter reads | +| `UpdateCoordinatorConfiguration` / `UpdateNodeConfiguration` | per-role parameter updates (LRO) | +| `CreateOrUpdatePrivateEndpointConnection` / `GetPrivateEndpointConnection` / `ListPrivateEndpointConnections` / `DeletePrivateEndpointConnection` | private-endpoint CRUD | +| `GetPrivateLinkResource` / `ListPrivateLinkResources` | private-link resource reads | + +Modeling: a cluster owns its firewall rules, roles, configurations, and +private-endpoint connections (parent linkage, cascade delete); nodes are derived +from the cluster shape (one coordinator + N workers); read replicas link back to +a source cluster and detach on promote; clone-on-read on every path. + +**Total: 34 operations** + +--- + ## 12. Secrets **Driver interface:** `services/secrets/driver/driver.go` @@ -1668,19 +1751,37 @@ It behaves like a tiny always-converged cluster (minikube-like) rather than a ba **Core (`core/v1`)**: Namespace, ConfigMap, Secret (StringData merged into Data), ServiceAccount (`default` auto-created per namespace), Pod (driven **Running** with a synthetic Pod IP — a directly-created Pod with a terminal phase is preserved), Service (ClusterIP from 10.96.0.0/12, immutable on update), Endpoints (get/list/watch only — auto-managed per Service), PersistentVolumeClaim (→ Bound), PersistentVolume (→ Available), Node, Event, ResourceQuota, LimitRange. -**Workload controllers (`apps/v1`)**: Deployment, ReplicaSet, StatefulSet (stable `-0..-N` names + one Bound PVC per `volumeClaimTemplate`), DaemonSet (one Pod per node). All materialize Running Pods owned via `ownerReferences`; deleting a controller cascade-deletes its Pods and drains Endpoints. A change to the pod template (e.g. image) is a **rolling update** — stale-hash Pods (tracked by a `pod-template-hash` label) are replaced. Deployments/StatefulSets expose **`/scale`** and **`/status`** subresources. +**Workload controllers (`apps/v1`)**: Deployment, ReplicaSet, StatefulSet (stable `-0..-N` names + one Bound PVC per `volumeClaimTemplate`), DaemonSet (one Pod per node whose labels satisfy the template `nodeSelector` — zero Pods when it doesn't match the synthetic node). A **Deployment interposes a ReplicaSet** per pod-template revision (Deployment→RS→Pod, matching real topology), and a template change creates a new ReplicaSet and deletes the old one outright — an instantaneous swap (no `revisionHistoryLimit`, no `kubectl rollout undo`, no surge/unavailable pacing). All materialize Running Pods owned via `ownerReferences`; deleting a controller cascade-deletes the chain and drains Endpoints. Deployments/StatefulSets expose **`/scale`** and **`/status`** subresources. CronJob scheduling is driven explicitly via `TickCronJobs()` (no background timer), which performs real due-evaluation against the cluster clock: it parses the standard 5-field `spec.schedule` (`*`, `*/n`, lists, `a-b` ranges) and materializes a Job only when a scheduled time falls in `(status.lastScheduleTime, now]` — advancing `lastScheduleTime` to the fired slot so re-ticking the same instant never double-creates — and honors `concurrencyPolicy` (`Forbid`/`Replace`/`Allow`) and `startingDeadlineSeconds`. + +**Other groups** (registry-backed CRUD + list/watch/patch/delete): `batch/v1` Job (→ Succeeded Pods) / CronJob; `networking.k8s.io/v1` Ingress (→ load-balancer IP) / IngressClass / NetworkPolicy; `rbac.authorization.k8s.io/v1` Role / RoleBinding / ClusterRole / ClusterRoleBinding; `storage.k8s.io/v1` StorageClass; `autoscaling/v2` HorizontalPodAutoscaler; `discovery.k8s.io/v1` EndpointSlice; `policy/v1` PodDisruptionBudget; `apiextensions.k8s.io/v1` CustomResourceDefinition; `admissionregistration.k8s.io/v1` Mutating/ValidatingWebhookConfiguration. + +**Custom resources (CRDs)**: creating a `CustomResourceDefinition` dynamically materializes a servable store for every served version — the custom-resource kind is then served by the generic handler (CRUD/list/watch/`/status`) and advertised in discovery immediately; the CRD is marked `Established`. Deleting the CRD deregisters the kind and cascade-deletes its custom resources — including when the CRD carries a finalizer, in which case teardown runs once the last finalizer drains. Structural schema validation of CRs is a documented simplification (accept-and-store). + +**Selectors & pagination**: label selectors on list; field selectors for `metadata.name` / `metadata.namespace`, Pod `status.phase` / `spec.nodeName`, and Event fields (`involvedObject.name/namespace/kind/uid`, `reason`, `type`). List responses honor **`?limit=&continue=`** chunked pagination across the registry and typed list paths: the `metadata.continue` token is key-anchored (it encodes the last object's `namespace/name`), so an insert or delete before that key cannot skip or duplicate later items under concurrent mutation, and a malformed token returns `410 Gone` (reason `Expired`) per client-go's pager contract. A well-formed token whose key was since deleted resumes gracefully at the next greater key rather than `410`-ing on a compacted resourceVersion — strictly more forgiving than upstream. + +**Patch & server-side apply**: JSON-merge-patch, JSONPatch (RFC 6902), and strategic-merge-patch (real strategic merge against the typed struct for core/apps kinds, so `kubectl set image` merges the container list by name). **Server-side apply** (`application/apply-patch+yaml`) tracks per-`fieldManager` field ownership in `metadata.managedFields`; an apply that changes a field owned by another manager returns **409 Conflict** unless `?force=true` (which transfers ownership), and an owner re-applying the same value is a no-op. A re-apply by the same manager that omits a field it previously owned removes that field, unless another manager also owns it. Plain PUT/PATCH updates record an `Update`-operation `managedFields` entry for their `fieldManager` (defaulted from the User-Agent when absent), taking or sharing ownership rather than conflicting (only Apply-vs-Apply is a 409). Ownership is tracked at leaf granularity (map keys / whole arrays) — per-element list merging is not modeled, a documented subset of upstream SSA. + +**Dry-run**: writes with `?dryRun=All` (`kubectl apply|create|delete --dry-run=server`) run validation, defaulting, and quota admission (a create against an at-limit namespace returns the same `403` a real create would), echo the object the server would store, and persist nothing — no resourceVersion bump, reconcile, quota reservation, or watch event. -**Other groups** (registry-backed CRUD + list/watch/patch/delete): `batch/v1` Job (→ Succeeded Pods) / CronJob; `networking.k8s.io/v1` Ingress (→ load-balancer IP) / IngressClass / NetworkPolicy; `rbac.authorization.k8s.io/v1` Role / RoleBinding / ClusterRole / ClusterRoleBinding; `storage.k8s.io/v1` StorageClass; `autoscaling/v2` HorizontalPodAutoscaler; `discovery.k8s.io/v1` EndpointSlice; `policy/v1` PodDisruptionBudget. +**Finalizers**: an object carrying `metadata.finalizers` goes **Terminating** on delete (`deletionTimestamp` stamped, object retained) and is removed only when the last finalizer is cleared via update/patch — on the registry path and typed Namespace/Pod. Finalizers are also honored during cascade: a finalizer-bearing child reached by owner garbage-collection or namespace teardown goes Terminating rather than being reaped, until its finalizers drain. The server-owned `deletionTimestamp` survives a merge-patch — an RFC-7396 `null` cannot resurrect a Terminating object. -**Selectors**: label selectors on list, and field selectors for the fields the store can answer (`metadata.name`, `metadata.namespace`, Pod `status.phase` / `spec.nodeName`). List responses are unpaginated — `limit`/`continue` are not honored (an emulation simplification; every list returns the full set in one response). +**Pod subresources**: `pods/{name}/log` returns synthetic container output; `exec`/`attach`/`portforward` return a typed `501` (they need a streaming protocol upgrade the emulator doesn't implement); `pods/{name}/eviction` honors PodDisruptionBudgets. -**Patch**: all four content types — JSON-merge-patch, JSONPatch (RFC 6902), and strategic-merge-patch (real strategic merge against the typed struct for core/apps kinds, so `kubectl set image` merges the container list by name). Server-side-apply is accepted and applied as a merge (an emulation simplification — apply field-ownership is not tracked). +**Metrics & autoscaling**: `metrics.k8s.io/v1beta1` (`kubectl top`) serves synthetic Pod/Node metrics from the live pods + synthetic node; a HorizontalPodAutoscaler reconcile drives its target Deployment on a Resource CPU `averageUtilization` metric — sampling the target Pods' CPU from that metrics source and applying the real HPA ratio `desiredReplicas = ceil(currentReplicas × currentUtilization ÷ targetUtilization)`, clamped into `[minReplicas, maxReplicas]` — and falls back to a plain min/max clamp when no CPU metric is configured or the target Pods declare no CPU request, reporting `currentReplicas`/`desiredReplicas`/`currentMetrics` on status. + +**Policy enforcement**: object-count **ResourceQuota** is enforced on create (403 over limit) and on server-side dry-run; `status.used` is updated on create and recomputed from the live count on delete (it tracks the live object count rather than climbing monotonically); **LimitRange** applies container defaults and min/max validation on pod create; **PodDisruptionBudget** gates `pods/eviction` (429 when eviction would violate the budget); **RBAC** is queryable via `authorization.k8s.io/v1` SubjectAccessReview (evaluated against stored Roles/ClusterRoles + bindings); **NetworkPolicy** is queryable via an in-process evaluation (no live traffic). + +**Admission webhooks** (opt-in): Mutating/ValidatingWebhookConfiguration objects store and round-trip through `kubectl apply`. With admission explicitly enabled (`APIServer.SetAdmissionEnabled`), create/update/patch calls matching webhooks apply mutations and honor denials (4xx); it is off by default so the data plane stays zero-network and deterministic. + +**Watch resume**: a watch with `resourceVersion>0` skips the initial snapshot replay and streams only subsequent events; `allowWatchBookmarks=true` emits a post-sync BOOKMARK carrying the current resourceVersion. A slow watcher that overflows its buffer gets a `410 Gone` so `client-go` relists. + +**Deterministic time**: every data-plane timestamp (creationTimestamp, pod start/conditions, managedFields) is sourced from an injectable clock (`APIServer.SetClock`); a `config.FakeClock` makes them fully deterministic for tests. **Watch streaming**: each list endpoint accepts `?watch=true` and upgrades to a `Transfer-Encoding: chunked` JSON event stream (`{"type":"ADDED|MODIFIED|DELETED","object":{...}}`). Initial state replays as ADDED events on subscribe, and the request's `labelSelector`/`fieldSelector` filters both the initial snapshot and live events, so `client-go` `Informer` / `SharedIndexInformer` machinery (operator-sdk, Helm, ArgoCD, …) — including selective informers — just works. A fresh cluster bootstraps a synthetic Ready node (`cloudemu-node-0`), and each selector Service's endpoints are mirrored into a `discovery.k8s.io` **EndpointSlice** so EndpointSlice-mode consumers see the same backends as the `Endpoints` object. -**Cascade**: deleting a Namespace or an owning controller publishes DELETED events for every child resource (garbage collection follows `ownerReferences`). +**Cascade**: deleting a Namespace or an owning controller publishes DELETED events for every child resource (garbage collection follows `ownerReferences`) — finalizer-bearing children instead go Terminating (MODIFIED) until drained. -**Non-goals** (deliberate emulation boundaries): no kubelet-backed Pod subresources (`/log`, `/exec`, `/attach`, `/portforward`); no real scheduling (all Pods land on a single synthetic node, no affinity/taints/resource-fit); no admission/quota/policy **enforcement** (ResourceQuota, LimitRange, NetworkPolicy, PodDisruptionBudget, RBAC are stored and served but not enforced); HPA does not actually autoscale; CronJob does not fire on a schedule; rollouts converge instantly with no surge/unavailable pacing or revision history; no aggregated API servers, admission webhooks, or CRD registration. +**Emulation boundaries** (deliberate simplifications, not gaps): there is no real kubelet — Pods are driven Running synthetically and `pods/log` is synthetic while `exec`/`attach`/`portforward` return a typed 501; no real scheduling beyond the single synthetic node (DaemonSet `nodeSelector` is honored, but affinity/taints/resource-fit are not); admission webhooks make outbound calls only when explicitly enabled (off by default to stay zero-network); server-side apply tracks ownership at leaf granularity (no per-element list merge); NetworkPolicy and RBAC are **queryable** (SubjectAccessReview / EvaluateNetworkPolicy) rather than request-time-enforced, since the emulator has no packet path or authenticated identity; CronJob has no wall-clock timer (schedules are evaluated only when `TickCronJobs` is called) and supports only the standard 5-field cron syntax (nonstandard `@`-macros, `L`/`W`/`#`/`?` characters, and seconds/year fields are rejected); rollouts converge instantly (no surge/unavailable pacing, minimal revision history); and OpenAPI is served cluster-independently, so CRD schemas aren't published there (custom resources still work via discovery). --- @@ -1742,6 +1843,38 @@ Relational databases follow the same pattern via a `RelationalDatabases` adapter |-----------|-------| | `Resources` | `POST /providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01` — KQL-shaped query over the unified inventory; supports `subscriptions[]` scoping and `$top`/`$skipToken` pagination | +**Cost-discovery field projection.** Each row projects the `sku` (name/tier/capacity) +and `properties` a real discoverer prices on, per Azure type: + +| Azure type | Projected fields | +|---|---| +| `microsoft.compute/virtualmachines` | `properties.priority` (Spot), `properties.licenseType`, `properties.storageProfile.osDisk.osType`, `sku.name`, `zones` | +| `microsoft.compute/disks` | `properties.diskIOPSReadWrite`, `properties.diskMBpsReadWrite`, `properties.diskSizeGB`, `properties.tier`, `sku.name`/`sku.tier` | +| `microsoft.compute/virtualmachinescalesets` | `sku.name`/`sku.capacity`, nested `properties.virtualMachineProfile.{priority,licenseType,storageProfile.osDisk.osType}` | +| `microsoft.network/publicipaddresses` | `sku.name` (Basic/Standard), `properties.publicIPAllocationMethod` | +| `microsoft.network/virtualnetworks` / `subnets` | `properties.addressSpace.addressPrefixes` / `properties.addressPrefix` | +| `microsoft.sql/managedinstances` | `sku.name`, `properties.vCores`, `properties.tier`, `properties.licenseType`, `properties.storageSizeInGB`, `properties.storageAccountType` (backup redundancy) | +| `microsoft.sql/servers` | `properties.version` (engine version of the logical server) | +| `microsoft.sql/servers/databases` | `sku.name`, `properties.currentSku`, `properties.zoneRedundant` | +| `microsoft.dbformysql`/`dbforpostgresql` `flexibleservers` | `sku.name`/`sku.tier` (derived), `properties.version`, nested `properties.storage.storageSizeGB` + `properties.highAvailability.mode` | +| `microsoft.containerservice/managedclusters` | `sku.tier`, `properties.powerState.code`, `properties.kubernetesVersion` | +| `.../managedclusters/agentpools` | `sku.name` (vmSize), `properties.scaleSetPriority` (Spot), `properties.count`, `properties.mode`/`osType` | +| `microsoft.databricks/workspaces` | `sku.name`/`sku.tier`, `properties.workspaceId`/`provisioningState` | +| `microsoft.storage/storageaccounts` | `sku.name` (redundancy), `kind`, `properties.accessTier` | +| `microsoft.documentdb/databaseaccounts` | `kind`, `properties.databaseAccountOfferType`, `properties.capabilities` (serverless), `properties.enableFreeTier` | +| `microsoft.web/serverfarms` (App Service plan) | `sku.name`/`sku.tier`/`sku.capacity` (pricing tier), `kind` | + +Fields are seeded through the portable driver configs (`VolumeConfig.IOPS/Throughput/Tier`, +`InstanceConfig.OSType/Priority/LicenseType/Zones`, `ManagedInstanceConfig.StorageAccountType`, +`ElasticIPConfig.SKU/AllocationMethod`, the AKS `Tier`/`ScaleSetPriority` inputs, the +`Databases` capability on Azure SQL, the VMSS `ScaleSets` + serverfarms `AppServicePlans` +discovery capabilities, and the optional `BucketAttributes` / `TableAttributes` capabilities +that enrich storage accounts and Cosmos DB) so a value set at create time round-trips over +the real `armresourcegraph` SDK. Storage/Cosmos/serverfarms follow the established discovery +patterns — optional type-asserted capabilities (like networking's `NetworkInterfaces`) for +per-resource enrichment, and provider-projected discovery adapters (like the relational-DB +and Kubernetes walkers) for the net-new plan/scale-set resources. + ### GCP — Cloud Asset Inventory (`server/gcp/cloudasset`) | Resource | Operations | @@ -1842,6 +1975,32 @@ Azure-only. The control plane backs the real `armdatabricks` SDK; the data plane | `ListWorkspacesByResourceGroup` | `(ctx, resourceGroup) ([]Workspace, error)` | | `ListWorkspaces` | `(ctx) ([]Workspace, error)` | +### Extended ARM resources (control plane) + +The rest of the `Microsoft.Databricks` ARM surface beyond workspaces +(`services/databricks/driver/arm_resources.go`), reachable over the real +`armdatabricks` SDK: + +| Resource | Operations | +|----------|------------| +| **Access Connectors** (`accessConnectors`) | `CreateOrUpdateAccessConnector`, `GetAccessConnector`, `UpdateAccessConnector`, `DeleteAccessConnector`, `ListAccessConnectorsByResourceGroup`, `ListAccessConnectors` | +| **Private Endpoint Connections** (`workspaces/{w}/privateEndpointConnections`) | `PutPrivateEndpointConnection`, `GetPrivateEndpointConnection`, `DeletePrivateEndpointConnection`, `ListPrivateEndpointConnections` | +| **Private Link Resources** (`workspaces/{w}/privateLinkResources`) | `GetPrivateLinkResource`, `ListPrivateLinkResources` | +| **VNet Peering** (`workspaces/{w}/virtualNetworkPeerings`) | `CreateOrUpdateVNetPeering`, `GetVNetPeering`, `DeleteVNetPeering`, `ListVNetPeerings` | +| **Outbound Network Dependencies** (`workspaces/{w}/outboundNetworkDependenciesEndpoints`) | `ListOutboundNetworkDependencies` | +| **Operations** (`/providers/Microsoft.Databricks/operations`) | `ListOperations` | + +*Modeled store-and-echo:* the ARM resources round-trip faithfully over the SDK +(access connectors and peerings persist and are listed/described; a +system-assigned access-connector identity gets synthesized principal/tenant +IDs; a created peering springs to `Connected`/`Succeeded`), but the underlying +Azure networking side effects are **not** simulated — a private-endpoint +connection stores its approval state without a real private endpoint on the +platform side, private-link resources and outbound-dependency endpoints are a +synthesized (workspace-scoped) catalog rather than a live probe, and a VNet +peering does not actually peer networks. The provider operations list is a +static catalog of the RBAC operations the namespace exposes. + ### Instance Pool Operations | Operation | Signature | @@ -1923,7 +2082,7 @@ Azure-only. The control plane backs the real `armdatabricks` SDK; the data plane | `SetPermissions` | `(ctx, objectType, objectID, acl) (*ObjectPermissions, error)` | | `UpdatePermissions` | `(ctx, objectType, objectID, acl) (*ObjectPermissions, error)` | -**Total: 52 operations** +**Total: 70 operations** --- @@ -2189,6 +2348,8 @@ still sees success. | Database | 21 | | Serverless | 26 | | Networking | 51 | +| Networking — AWS-specific (Transit Gateway / VPN / DHCP / prefix lists / egress-only IGW / endpoint services / Client VPN / Traffic Mirroring / Network Insights / VPC Block Public Access / IPAM full incl. discovery/BYOASN/BYOIP/resolver/policy + AWS/IPAM metrics) | 162 | +| Network Firewall — AWS | 20 | | Monitoring | 12 | | IAM | 35 | | DNS | 15 | @@ -2199,6 +2360,7 @@ still sees success. | Keyspaces — AWS (Cassandra control plane) | 18 (+1 optional) | | Managed Cassandra — Azure (Cosmos DB) | 15 | | Bigtable — GCP (wide-column NoSQL) | 38 | +| Cosmos DB for PostgreSQL — Azure (Citus) | 34 | | Secrets | 7 | | Logging | 13 | | Notification | 8 | @@ -2212,13 +2374,13 @@ still sees success. | Resource Discovery (engine + AWS + Azure + GCP handlers) | 26 | | Generative AI — AWS Bedrock (control plane + runtime) | 65 | | Generative AI — AWS Bedrock Agent (control plane + runtime) | 32 | -| Databricks — Azure (control + data plane) | 52 | +| Databricks — Azure (control + data plane) | 70 | | Machine Learning — AWS SageMaker (control plane + runtime) | 121 | | Machine Learning — Azure AI (CognitiveServices + MachineLearningServices + data plane) | 92 | | Machine Learning — GCP Vertex AI (Go API/driver) | 128 | | AI Search — Azure AI Search (control + data plane) | 53 | | Container Orchestration — AWS ECS | 37 | -| **Grand Total** | **1381** (+138 optional) | +| **Grand Total** | **1562** (+138 optional) | Optional operations are capabilities a driver may implement but is not required to; see the sections marked "optional capability". They are counted separately diff --git a/features/chaos/wrappers_test.go b/features/chaos/wrappers_test.go index 24fec66f..652939b1 100644 --- a/features/chaos/wrappers_test.go +++ b/features/chaos/wrappers_test.go @@ -421,6 +421,10 @@ type computeConfigCompat = struct { UserData string Managed bool Principal string + OSType string + Priority string + LicenseType string + Zones []string } // computeInstanceConfig reproduces the helper used elsewhere in the package diff --git a/go.mod b/go.mod index 6eda74ad..41ddd797 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/stackshy/cloudemu/v2 go 1.25.0 require ( + cloud.google.com/go/artifactregistry v1.20.0 cloud.google.com/go/compute v1.60.0 + cloud.google.com/go/eventarc v1.18.0 cloud.google.com/go/firestore v1.22.0 cloud.google.com/go/storage v1.62.1 github.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.7.2 @@ -17,6 +19,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v5 v5.7.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v6 v6.6.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3 v3.4.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmosforpostgresql/armcosmosforpostgresql v1.1.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks v1.1.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventgrid/armeventgrid/v2 v2.3.0 @@ -31,10 +34,11 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/search/armsearch v1.4.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/servicebus/armservicebus/v2 v2.0.0-beta.3 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 github.com/Azure/azure-sdk-for-go/sdk/storage/azqueue v1.0.1 - github.com/aws/aws-sdk-go-v2 v1.43.2 + github.com/aws/aws-sdk-go-v2 v1.43.3 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 github.com/aws/aws-sdk-go-v2/config v1.32.14 github.com/aws/aws-sdk-go-v2/credentials v1.19.14 @@ -59,6 +63,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/lambda v1.90.1 github.com/aws/aws-sdk-go-v2/service/memorydb v1.36.2 github.com/aws/aws-sdk-go-v2/service/neptune v1.44.5 + github.com/aws/aws-sdk-go-v2/service/networkfirewall v1.66.0 github.com/aws/aws-sdk-go-v2/service/rds v1.118.2 github.com/aws/aws-sdk-go-v2/service/redshift v1.62.7 github.com/aws/aws-sdk-go-v2/service/resourceexplorer2 v1.23.6 @@ -73,7 +78,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sqs v1.42.27 github.com/aws/aws-sdk-go-v2/service/ssm v1.71.0 github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 - github.com/aws/smithy-go v1.27.5 + github.com/aws/smithy-go v1.27.6 github.com/databricks/databricks-sdk-go v0.144.0 github.com/fxamacker/cbor/v2 v2.9.1 github.com/google/gnostic-models v0.6.8 @@ -103,8 +108,8 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14 // indirect diff --git a/go.sum b/go.sum index 968f5481..4ffd081a 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/artifactregistry v1.20.0 h1:j/XQiQfaeTyQeNj3HNk4iDFREVnY/fxkHIjsxpaDs8A= +cloud.google.com/go/artifactregistry v1.20.0/go.mod h1:0G9wdbGyDFkvrYH+2AlQs9MuTJdbY8Vg45M8VjlI8rc= cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ= cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= @@ -10,6 +12,8 @@ cloud.google.com/go/compute v1.60.0 h1:CqGt23ysz990ZZe1vq/9aDPKKnmwM6kcC7Y1Q05H2 cloud.google.com/go/compute v1.60.0/go.mod h1:Xm6PbsLgBpAg4va77ljbBdpMjzuU+uPp5Ze2dnZq7lw= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/eventarc v1.18.0 h1:8WWG1/ogInYur1NQjML6EMHQ0ZBzAdMDGlUVpLD56cI= +cloud.google.com/go/eventarc v1.18.0/go.mod h1:/6SDoqh5+9QNUqCX4/oQcJVK16fG/snHBSXu7lrJtO8= cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E= cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU= cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= @@ -54,6 +58,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontai github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v6 v6.6.0/go.mod h1:OWKfCmX4X3Vp2w7GSx1LZn8566tOHJBA6K0IAUVNYx0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3 v3.4.0 h1:+EhRnIOLvffCvUMUfP+MgOp6PrtN1d6xt94DZtrC3lA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3 v3.4.0/go.mod h1:Bb7kqorvA2acMCNFac+2ldoQWi7QrcMdH+9Gg9C7fSM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmosforpostgresql/armcosmosforpostgresql v1.1.0 h1:TyXI0pf9V67/vn7Vo2BebOz4B/fLj9Kt3UcrQBXMrvE= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmosforpostgresql/armcosmosforpostgresql v1.1.0/go.mod h1:s//ycXE53yRslaDdkNrCEANgvrdSOaUuqcBCJg5VEX0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks v1.1.0 h1:rQyNHB/4ntzvm5F9WAiaAl7jWII+jaI4rL6sSWxTNeM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks v1.1.0/go.mod h1:4jtknLqzaPtwIz8Y9NBp2rXxeA7BbSICWBD0FDzG2VM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c= @@ -110,8 +116,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= -github.com/aws/aws-sdk-go-v2 v1.43.2 h1:cl+IXwWb3qazClUcm08tGSsB6OiuV83JVJO9B0jQcPc= -github.com/aws/aws-sdk-go-v2 v1.43.2/go.mod h1:WEzLKBh/mEjXvx1FtQMWgSxMSTVqxQzjkRtk5fa3wkg= +github.com/aws/aws-sdk-go-v2 v1.43.3 h1:XJIcfv8uDs2ukdQsoAC8/Ebu1ejxwzlayl2ZsiFns2A= +github.com/aws/aws-sdk-go-v2 v1.43.3/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= @@ -120,10 +126,10 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8TH github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33 h1:HAp1wLFZzch054uh3FK7rcVYg4v7J2FxVf3h3IGNZas= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33/go.mod h1:mJk5fmqnF+WUlMdPG37pR2Fh3oh6r8F6ZGUgPKvzu0c= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33 h1:0YA0aCKgsJyno6xkFfaIgjE3/wK08+Qxo9nQfe1UrWM= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33/go.mod h1:UZqj4WIdTH+ga8Y/DgpAuy/8cGjM3h7gDCliJYGg2SE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34 h1:vuIfjzoeqhQMGJyOBU3t0ZEjn2jrN8Bbg1N4CgjzM5Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34/go.mod h1:hP28cN4CPJLZHirdQPrZR50JcLN4ApRJP2tzG8cRlhY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34 h1:9faHsnqxJ1vDvB4wMZy/ajIDyz5QhllQjjc72RJpXAw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34/go.mod h1:Yp6nIyejpa23nzlB/LhT63KTla9Jdi06nv/HH/OkAH8= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ= @@ -180,6 +186,8 @@ github.com/aws/aws-sdk-go-v2/service/memorydb v1.36.2 h1:GKkUvwhxKF5JVSWERui4bvP github.com/aws/aws-sdk-go-v2/service/memorydb v1.36.2/go.mod h1:wRcgz/DYIlZldpS/RpJDgjtMPsk0a6hLvwMl/2tGKuY= github.com/aws/aws-sdk-go-v2/service/neptune v1.44.5 h1:v+XVk5OQnhm6EasbClY036A1gTtVdtdNdG8/Q3rLYU4= github.com/aws/aws-sdk-go-v2/service/neptune v1.44.5/go.mod h1:Y/yH7q6R/qaO4g6YTBbAMSu5T4NjVo+7+P1UNZ2NlzA= +github.com/aws/aws-sdk-go-v2/service/networkfirewall v1.66.0 h1:VrfvjFVgN2ZJZUvMBsW0YGW7hN3zsqdVYy/wpko9hkQ= +github.com/aws/aws-sdk-go-v2/service/networkfirewall v1.66.0/go.mod h1:tR82E7iVkRt7zwjxj39m/TNt161xEH/pWuJFXtRhI0I= github.com/aws/aws-sdk-go-v2/service/rds v1.118.2 h1:pkEeQneYFpTAnGhyqSbyp/DlCPPJTGt0GkWahlLYzMA= github.com/aws/aws-sdk-go-v2/service/rds v1.118.2/go.mod h1:7gS+cGrKF0mH253QHFlStmx79ws+DlNk+04ZRfmw3U0= github.com/aws/aws-sdk-go-v2/service/redshift v1.62.7 h1:6NCQBp9IIEs51YI9/jT5/ckSd6Ka9956gAGlw0a0yFI= @@ -214,8 +222,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6f github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= -github.com/aws/smithy-go v1.27.5 h1:d1ro7KpYOYwP6m73YFa+Kc/A130VsAdX68SpsJwARMM= -github.com/aws/smithy-go v1.27.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= +github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= diff --git a/providers/aws/aws.go b/providers/aws/aws.go index 3ebb8c2b..8f2650fd 100644 --- a/providers/aws/aws.go +++ b/providers/aws/aws.go @@ -25,6 +25,7 @@ import ( "github.com/stackshy/cloudemu/v2/providers/aws/keyspaces" "github.com/stackshy/cloudemu/v2/providers/aws/lambda" "github.com/stackshy/cloudemu/v2/providers/aws/memorydb" + "github.com/stackshy/cloudemu/v2/providers/aws/networkfirewall" "github.com/stackshy/cloudemu/v2/providers/aws/rds" "github.com/stackshy/cloudemu/v2/providers/aws/redshift" "github.com/stackshy/cloudemu/v2/providers/aws/route53" @@ -84,7 +85,7 @@ func (a eksDiscovery) DiscoverClusters(ctx context.Context) ([]resourcediscovery return nil, err } - dc := resourcediscovery.DiscoveredCluster{Name: name, NodeGroups: ngs} + dc := resourcediscovery.DiscoveredCluster{Name: name, NodeGroups: resourcediscovery.NodeGroupsFromNames(ngs)} if c != nil { dc.ARN = c.ARN // use the EKS mock's own ARN verbatim // Keep Region in step with the verbatim ARN so the node-group ARN @@ -128,6 +129,7 @@ type Provider struct { ElastiCache *elasticache.Mock Keyspaces *keyspaces.Mock MemoryDB *memorydb.Mock + NetworkFirewall *networkfirewall.Mock SecretsManager *secretsmanager.Mock CloudWatchLogs *cloudwatchlogs.Mock SNS *sns.Mock @@ -164,6 +166,7 @@ func New(opts ...config.Option) *Provider { ElastiCache: elasticache.New(o), Keyspaces: keyspaces.New(o), MemoryDB: memorydb.New(o), + NetworkFirewall: networkfirewall.New(o), SecretsManager: secretsmanager.New(o), CloudWatchLogs: cloudwatchlogs.New(o), SNS: sns.New(o), @@ -202,6 +205,12 @@ func New(opts ...config.Option) *Provider { p.Redshift.SetMonitoring(p.CloudWatch) p.EKS.SetMonitoring(p.CloudWatch) p.SageMaker.SetMonitoring(p.CloudWatch) + // SNS -> SQS fan-out: publishes deliver to SQS-protocol subscriptions. + p.SNS.SetSQSDeliverer(p.SQS) + // EventBridge -> SQS: matched rules deliver events to SQS targets. + p.EventBridge.SetSQSDeliverer(p.SQS) + // S3 -> SQS: object-create events deliver to bucket notification targets. + p.S3.SetSQSDeliverer(p.SQS) p.ResourceDiscovery = resourcediscovery.New( resourcediscovery.ProviderAWS, o.AccountID, o.Region, diff --git a/providers/aws/awsiam/iam.go b/providers/aws/awsiam/iam.go index 33e0329c..da0853cb 100644 --- a/providers/aws/awsiam/iam.go +++ b/providers/aws/awsiam/iam.go @@ -53,6 +53,7 @@ type roleData struct { Path string AssumeRolePolicyDoc string Tags map[string]string + inlinePolicies map[string]string // policyName -> policy document JSON } type policyData struct { diff --git a/providers/aws/awsiam/rolepolicy.go b/providers/aws/awsiam/rolepolicy.go new file mode 100644 index 00000000..f7704836 --- /dev/null +++ b/providers/aws/awsiam/rolepolicy.go @@ -0,0 +1,87 @@ +package awsiam + +import ( + "context" + "sort" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// PutRolePolicy adds or replaces an inline policy on a role (IAM +// PutRolePolicy). Inline policies are embedded in the role, distinct from the +// managed policies attached via AttachRolePolicy. +func (m *Mock) PutRolePolicy(_ context.Context, roleName, policyName, policyDocument string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.roles.Get(roleName) + if !ok { + return errors.Newf(errors.NotFound, "role %q not found", roleName) + } + + if rd.inlinePolicies == nil { + rd.inlinePolicies = make(map[string]string) + } + + rd.inlinePolicies[policyName] = policyDocument + + return nil +} + +// GetRolePolicy returns an inline policy document by name (IAM GetRolePolicy). +func (m *Mock) GetRolePolicy(_ context.Context, roleName, policyName string) (string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + rd, ok := m.roles.Get(roleName) + if !ok { + return "", errors.Newf(errors.NotFound, "role %q not found", roleName) + } + + doc, ok := rd.inlinePolicies[policyName] + if !ok { + return "", errors.Newf(errors.NotFound, "policy %q not found on role %q", policyName, roleName) + } + + return doc, nil +} + +// DeleteRolePolicy removes an inline policy from a role (IAM DeleteRolePolicy). +func (m *Mock) DeleteRolePolicy(_ context.Context, roleName, policyName string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.roles.Get(roleName) + if !ok { + return errors.Newf(errors.NotFound, "role %q not found", roleName) + } + + if _, ok := rd.inlinePolicies[policyName]; !ok { + return errors.Newf(errors.NotFound, "policy %q not found on role %q", policyName, roleName) + } + + delete(rd.inlinePolicies, policyName) + + return nil +} + +// ListRolePolicies returns the names of a role's inline policies, sorted (IAM +// ListRolePolicies). +func (m *Mock) ListRolePolicies(_ context.Context, roleName string) ([]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + rd, ok := m.roles.Get(roleName) + if !ok { + return nil, errors.Newf(errors.NotFound, "role %q not found", roleName) + } + + names := make([]string, 0, len(rd.inlinePolicies)) + for name := range rd.inlinePolicies { + names = append(names, name) + } + + sort.Strings(names) + + return names, nil +} diff --git a/providers/aws/awsiam/roletags.go b/providers/aws/awsiam/roletags.go new file mode 100644 index 00000000..ab079dba --- /dev/null +++ b/providers/aws/awsiam/roletags.go @@ -0,0 +1,63 @@ +package awsiam + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// TagRole adds or overwrites tags on a role (IAM TagRole). +func (m *Mock) TagRole(_ context.Context, roleName string, tags map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.roles.Get(roleName) + if !ok { + return errors.Newf(errors.NotFound, "role %q not found", roleName) + } + + if rd.Tags == nil { + rd.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + rd.Tags[k] = v + } + + return nil +} + +// UntagRole removes tags by key from a role (IAM UntagRole). +func (m *Mock) UntagRole(_ context.Context, roleName string, keys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.roles.Get(roleName) + if !ok { + return errors.Newf(errors.NotFound, "role %q not found", roleName) + } + + for _, k := range keys { + delete(rd.Tags, k) + } + + return nil +} + +// ListRoleTags returns a role's tags (IAM ListRoleTags). +func (m *Mock) ListRoleTags(_ context.Context, roleName string) (map[string]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + rd, ok := m.roles.Get(roleName) + if !ok { + return nil, errors.Newf(errors.NotFound, "role %q not found", roleName) + } + + out := make(map[string]string, len(rd.Tags)) + for k, v := range rd.Tags { + out[k] = v + } + + return out, nil +} diff --git a/providers/aws/cloudwatch/cloudwatch.go b/providers/aws/cloudwatch/cloudwatch.go index cb0d1180..d7ab508f 100644 --- a/providers/aws/cloudwatch/cloudwatch.go +++ b/providers/aws/cloudwatch/cloudwatch.go @@ -309,6 +309,37 @@ func (m *Mock) ListMetrics(_ context.Context, namespace string) ([]string, error return names, nil } +// ListMetricsDetailed returns every stored metric as a (namespace, name) pair. +// ListMetrics filters by an exact namespace, so a namespace-less "list all" +// call needs this to return real metrics tagged with their true namespace. +func (m *Mock) ListMetricsDetailed(_ context.Context) ([]driver.MetricIdentifier, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + seen := make(map[metricKey]bool, len(m.metrics)) + out := make([]driver.MetricIdentifier, 0, len(m.metrics)) + + for key := range m.metrics { + if seen[key] { + continue + } + + seen[key] = true + + out = append(out, driver.MetricIdentifier{Namespace: key.Namespace, MetricName: key.MetricName}) + } + + sort.Slice(out, func(i, j int) bool { + if out[i].Namespace != out[j].Namespace { + return out[i].Namespace < out[j].Namespace + } + + return out[i].MetricName < out[j].MetricName + }) + + return out, nil +} + // CreateAlarm creates or updates an alarm with the given configuration. // //nolint:gocritic // hugeParam: interface method signature cannot be changed. diff --git a/providers/aws/cloudwatchlogs/cloudwatchlogs_test.go b/providers/aws/cloudwatchlogs/cloudwatchlogs_test.go index 413a9d8b..d80e63b8 100644 --- a/providers/aws/cloudwatchlogs/cloudwatchlogs_test.go +++ b/providers/aws/cloudwatchlogs/cloudwatchlogs_test.go @@ -815,3 +815,30 @@ func TestDescribeMetricFilters(t *testing.T) { require.Error(t, err) }) } + +// TestLogGroupTagging is a regression guard for issue #319: CloudWatch Logs +// TagResource/UntagResource/ListTagsForResource were unimplemented. +func TestLogGroupTagging(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, err := m.CreateLogGroup(ctx, driver.LogGroupConfig{Name: "g"}) + require.NoError(t, err) + + require.NoError(t, m.TagLogGroup(ctx, "g", map[string]string{"env": "prod", "team": "ops"})) + + tags, err := m.ListLogGroupTags(ctx, "g") + require.NoError(t, err) + assert.Equal(t, "prod", tags["env"]) + assert.Equal(t, "ops", tags["team"]) + + require.NoError(t, m.UntagLogGroup(ctx, "g", []string{"env"})) + + tags, err = m.ListLogGroupTags(ctx, "g") + require.NoError(t, err) + _, has := tags["env"] + assert.False(t, has) + assert.Equal(t, "ops", tags["team"]) + + assert.Error(t, m.TagLogGroup(ctx, "missing", map[string]string{"a": "b"})) +} diff --git a/providers/aws/cloudwatchlogs/tags.go b/providers/aws/cloudwatchlogs/tags.go new file mode 100644 index 00000000..2e4b8162 --- /dev/null +++ b/providers/aws/cloudwatchlogs/tags.go @@ -0,0 +1,55 @@ +package cloudwatchlogs + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// TagLogGroup adds or overwrites tags on a log group (CloudWatch Logs +// TagResource / TagLogGroup). +func (m *Mock) TagLogGroup(_ context.Context, name string, tags map[string]string) error { + g, ok := m.groups.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "log group %q not found", name) + } + + if g.info.Tags == nil { + g.info.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + g.info.Tags[k] = v + } + + return nil +} + +// UntagLogGroup removes tags by key from a log group. +func (m *Mock) UntagLogGroup(_ context.Context, name string, keys []string) error { + g, ok := m.groups.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "log group %q not found", name) + } + + for _, k := range keys { + delete(g.info.Tags, k) + } + + return nil +} + +// ListLogGroupTags returns a log group's tags. +func (m *Mock) ListLogGroupTags(_ context.Context, name string) (map[string]string, error) { + g, ok := m.groups.Get(name) + if !ok { + return nil, errors.Newf(errors.NotFound, "log group %q not found", name) + } + + out := make(map[string]string, len(g.info.Tags)) + for k, v := range g.info.Tags { + out[k] = v + } + + return out, nil +} diff --git a/providers/aws/dynamodb/dynamodb.go b/providers/aws/dynamodb/dynamodb.go index bb4abcd7..25022a4f 100644 --- a/providers/aws/dynamodb/dynamodb.go +++ b/providers/aws/dynamodb/dynamodb.go @@ -347,6 +347,12 @@ func (m *Mock) matchQueryItems( } } + // Apply the FilterExpression (post key-condition), matching real + // DynamoDB: Query filters the key-matched set the same way Scan does. + if !matchesFilters(item, input.Filters) { + continue + } + matched = append(matched, item) } diff --git a/providers/aws/ec2/ec2.go b/providers/aws/ec2/ec2.go index 233b2b07..fb9ed434 100644 --- a/providers/aws/ec2/ec2.go +++ b/providers/aws/ec2/ec2.go @@ -404,7 +404,7 @@ func (m *Mock) describeCandidates(instanceIDs []string, hidden, includeManaged b for _, id := range instanceIDs { inst, ok := m.instances.Get(id) if !ok { - continue + return nil, cerrors.Newf(cerrors.NotFound, "instance %q not found", id) } if hiddenManaged(inst, hidden, includeManaged) { @@ -582,6 +582,9 @@ func (m *Mock) CreateVolume(_ context.Context, cfg driver.VolumeConfig) (*driver AvailabilityZone: az, CreatedAt: m.opts.Clock.Now().UTC().Format("2006-01-02T15:04:05Z"), Tags: copyTags(cfg.Tags), + IOPS: cfg.IOPS, + Throughput: cfg.Throughput, + Tier: cfg.Tier, } m.volumes.Set(id, vol) @@ -607,8 +610,16 @@ func (m *Mock) DeleteVolume(_ context.Context, id string) error { return nil } -// DescribeVolumes returns volumes matching the given IDs. +// DescribeVolumes returns volumes matching the given IDs. An explicit ID that +// does not exist yields InvalidVolume.NotFound, matching real EC2 (an empty +// success would break existence checks and Terraform drift detection). func (m *Mock) DescribeVolumes(_ context.Context, ids []string) ([]driver.VolumeInfo, error) { + for _, id := range ids { + if !m.volumes.Has(id) { + return nil, cerrors.Newf(cerrors.NotFound, "volume %q not found", id) + } + } + return describeResources(m.volumes, ids), nil } diff --git a/providers/aws/ec2/ec2_test.go b/providers/aws/ec2/ec2_test.go index e4231113..34a02fe3 100644 --- a/providers/aws/ec2/ec2_test.go +++ b/providers/aws/ec2/ec2_test.go @@ -376,10 +376,10 @@ func TestDeleteVolume(t *testing.T) { err = m.DeleteVolume(ctx, vol.ID) requireNoError(t, err) - // Should be gone - vols, err := m.DescribeVolumes(ctx, []string{vol.ID}) - requireNoError(t, err) - assertEqual(t, 0, len(vols)) + // Should be gone: describing the deleted ID now yields NotFound, + // matching real EC2 (InvalidVolume.NotFound) rather than empty success. + _, err = m.DescribeVolumes(ctx, []string{vol.ID}) + assertError(t, err, true) }) t.Run("not found", func(t *testing.T) { diff --git a/providers/aws/ec2/tags.go b/providers/aws/ec2/tags.go new file mode 100644 index 00000000..07e099b9 --- /dev/null +++ b/providers/aws/ec2/tags.go @@ -0,0 +1,94 @@ +package ec2 + +import ( + "context" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// tagsOf resolves an EC2 resource ID (by prefix) to its mutable tag map. +// Returns false when the ID is unknown or the resource does not exist. +func (m *Mock) tagsOf(id string) (map[string]string, bool) { + switch { + case strings.HasPrefix(id, "i-"): + if d, ok := m.instances.Get(id); ok { + if d.Tags == nil { + d.Tags = map[string]string{} + } + + return d.Tags, true + } + case strings.HasPrefix(id, "vol-"): + if d, ok := m.volumes.Get(id); ok { + if d.Tags == nil { + d.Tags = map[string]string{} + } + + return d.Tags, true + } + case strings.HasPrefix(id, "snap-"): + if d, ok := m.snapshots.Get(id); ok { + if d.Tags == nil { + d.Tags = map[string]string{} + } + + return d.Tags, true + } + case strings.HasPrefix(id, "ami-"): + if d, ok := m.images.Get(id); ok { + if d.Tags == nil { + d.Tags = map[string]string{} + } + + return d.Tags, true + } + } + + return nil, false +} + +// TagResource applies tags to an EC2 instance, volume, snapshot, or image by +// ID. This backs the EC2 CreateTags API for compute resources (VPC-family IDs +// are handled by the networking provider). Returns NotFound for an unknown ID. +func (m *Mock) TagResource(_ context.Context, id string, tags map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + dst, ok := m.tagsOf(id) + if !ok { + return cerrors.Newf(cerrors.NotFound, "resource %q not found", id) + } + + for k, v := range tags { + dst[k] = v + } + + return nil +} + +// UntagResource removes tags by key from an EC2 resource. An empty key list +// clears all tags, matching EC2 DeleteTags semantics. +func (m *Mock) UntagResource(_ context.Context, id string, keys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + dst, ok := m.tagsOf(id) + if !ok { + return cerrors.Newf(cerrors.NotFound, "resource %q not found", id) + } + + if len(keys) == 0 { + for k := range dst { + delete(dst, k) + } + + return nil + } + + for _, k := range keys { + delete(dst, k) + } + + return nil +} diff --git a/providers/aws/ecr/authtoken.go b/providers/aws/ecr/authtoken.go new file mode 100644 index 00000000..55556406 --- /dev/null +++ b/providers/aws/ecr/authtoken.go @@ -0,0 +1,24 @@ +package ecr + +import ( + "context" + "encoding/base64" + "fmt" + "time" +) + +// authTokenTTL is how long a GetAuthorizationToken response stays valid. Real +// ECR tokens last 12 hours. +const authTokenTTL = 12 * time.Hour + +// GetAuthorizationToken returns a base64 "AWS:" credential, the +// registry proxy endpoint, and an expiry — everything `docker login` and +// image push/pull need. The emulator does not validate the token on later +// requests; it exists so auth flows succeed. +func (m *Mock) GetAuthorizationToken(_ context.Context) (token, proxyEndpoint string, expiresAt time.Time, err error) { + token = base64.StdEncoding.EncodeToString([]byte("AWS:cloudemu")) + proxyEndpoint = fmt.Sprintf("https://%s.dkr.ecr.%s.amazonaws.com", m.opts.AccountID, m.opts.Region) + expiresAt = m.opts.Clock.Now().Add(authTokenTTL).UTC() + + return token, proxyEndpoint, expiresAt, nil +} diff --git a/providers/aws/ecr/ecr.go b/providers/aws/ecr/ecr.go index 84514e0e..8f76634f 100644 --- a/providers/aws/ecr/ecr.go +++ b/providers/aws/ecr/ecr.go @@ -38,6 +38,7 @@ type repoData struct { images *memstore.Store[*imageData] scans *memstore.Store[*driver.ScanResult] policy *driver.LifecyclePolicy + repoPolicy string // resource (permissions) policy JSON, set via SetRepositoryPolicy scanOnPush bool tagMutability string } diff --git a/providers/aws/ecr/ecr_test.go b/providers/aws/ecr/ecr_test.go index 96927d3a..03aeb24a 100644 --- a/providers/aws/ecr/ecr_test.go +++ b/providers/aws/ecr/ecr_test.go @@ -801,3 +801,28 @@ func TestMetricsEmission(t *testing.T) { assert.Contains(t, metrics, "ImagePullCount") }) } + +// TestRepositoryTagging is a regression guard for issue #319: ECR +// TagResource/UntagResource/ListTagsForResource were unimplemented. +func TestRepositoryTagging(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + + createTestRepo(t, m, "r1") + + require.NoError(t, m.TagRepository(ctx, "r1", map[string]string{"env": "prod", "team": "img"})) + + tags, err := m.ListRepositoryTags(ctx, "r1") + require.NoError(t, err) + assert.Equal(t, "prod", tags["env"]) + assert.Equal(t, "img", tags["team"]) + + require.NoError(t, m.UntagRepository(ctx, "r1", []string{"env"})) + + tags, err = m.ListRepositoryTags(ctx, "r1") + require.NoError(t, err) + _, has := tags["env"] + assert.False(t, has) + + assert.Error(t, m.TagRepository(ctx, "missing", map[string]string{"a": "b"})) +} diff --git a/providers/aws/ecr/repopolicy.go b/providers/aws/ecr/repopolicy.go new file mode 100644 index 00000000..62b6559f --- /dev/null +++ b/providers/aws/ecr/repopolicy.go @@ -0,0 +1,60 @@ +package ecr + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// SetRepositoryPolicy stores a repository's resource (permissions) policy. +func (m *Mock) SetRepositoryPolicy(_ context.Context, repository, policyText string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.repos.Get(repository) + if !ok { + return "", errors.Newf(errors.NotFound, "repository %q not found", repository) + } + + rd.repoPolicy = policyText + + return rd.repoPolicy, nil +} + +// GetRepositoryPolicy returns a repository's resource policy, or NotFound if +// none is set. +func (m *Mock) GetRepositoryPolicy(_ context.Context, repository string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.repos.Get(repository) + if !ok { + return "", errors.Newf(errors.NotFound, "repository %q not found", repository) + } + + if rd.repoPolicy == "" { + return "", errors.Newf(errors.NotFound, "no repository policy for %q", repository) + } + + return rd.repoPolicy, nil +} + +// DeleteRepositoryPolicy removes a repository's resource policy. +func (m *Mock) DeleteRepositoryPolicy(_ context.Context, repository string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.repos.Get(repository) + if !ok { + return "", errors.Newf(errors.NotFound, "repository %q not found", repository) + } + + if rd.repoPolicy == "" { + return "", errors.Newf(errors.NotFound, "no repository policy for %q", repository) + } + + policy := rd.repoPolicy + rd.repoPolicy = "" + + return policy, nil +} diff --git a/providers/aws/ecr/tags.go b/providers/aws/ecr/tags.go new file mode 100644 index 00000000..bfeb6703 --- /dev/null +++ b/providers/aws/ecr/tags.go @@ -0,0 +1,63 @@ +package ecr + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// TagRepository adds or overwrites tags on a repository (ECR TagResource). +func (m *Mock) TagRepository(_ context.Context, name string, tags map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.repos.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "repository %q not found", name) + } + + if rd.info.Tags == nil { + rd.info.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + rd.info.Tags[k] = v + } + + return nil +} + +// UntagRepository removes tags by key from a repository (ECR UntagResource). +func (m *Mock) UntagRepository(_ context.Context, name string, keys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.repos.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "repository %q not found", name) + } + + for _, k := range keys { + delete(rd.info.Tags, k) + } + + return nil +} + +// ListRepositoryTags returns a repository's tags (ECR ListTagsForResource). +func (m *Mock) ListRepositoryTags(_ context.Context, name string) (map[string]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.repos.Get(name) + if !ok { + return nil, errors.Newf(errors.NotFound, "repository %q not found", name) + } + + out := make(map[string]string, len(rd.info.Tags)) + for k, v := range rd.info.Tags { + out[k] = v + } + + return out, nil +} diff --git a/providers/aws/eks/eks.go b/providers/aws/eks/eks.go index 96c473fc..7200eea8 100644 --- a/providers/aws/eks/eks.go +++ b/providers/aws/eks/eks.go @@ -30,9 +30,10 @@ import ( // Wave 1 placeholder for the cluster API server endpoint. Wave 2 will swap // in a real per-cluster apiserver address. const ( - wavePlaceholderEndpoint = "https://EKS-DATAPLANE-NOT-IMPLEMENTED.cloudemu.local" - defaultPlatformVersion = "eks.1" - namespaceEKS = "AWS/EKS" + wavePlaceholderEndpoint = "https://EKS-DATAPLANE-NOT-IMPLEMENTED.cloudemu.local" + defaultPlatformVersion = "eks.1" + defaultKubernetesVersion = "1.29" + namespaceEKS = "AWS/EKS" ) // CloudWatch-style metric values emitted on cluster create. The numbers are @@ -243,10 +244,17 @@ func (m *Mock) CreateCluster(_ context.Context, cfg eksdriver.ClusterConfig) (*e return nil, cerrors.Newf(cerrors.AlreadyExists, "cluster %q already exists", cfg.Name) } + version := cfg.Version + if version == "" { + // Real EKS defaults to the latest supported Kubernetes version when the + // caller omits it, rather than returning a null version. + version = defaultKubernetesVersion + } + cluster := eksdriver.Cluster{ Name: cfg.Name, ARN: m.clusterARN(cfg.Name), - Version: cfg.Version, + Version: version, PlatformVersion: defaultPlatformVersion, RoleArn: cfg.RoleArn, Endpoint: wavePlaceholderEndpoint, diff --git a/providers/aws/eks/tags.go b/providers/aws/eks/tags.go new file mode 100644 index 00000000..586313ea --- /dev/null +++ b/providers/aws/eks/tags.go @@ -0,0 +1,79 @@ +package eks + +import ( + "context" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// clusterNameFromARN resolves an EKS cluster ARN +// ("arn:aws:eks:::cluster/") to the bare cluster name. +// A non-ARN value is returned unchanged. +func clusterNameFromARN(arn string) string { + const marker = ":cluster/" + + if i := strings.LastIndex(arn, marker); i >= 0 { + return arn[i+len(marker):] + } + + return arn +} + +// TagResource adds or overwrites tags on a cluster identified by ARN (EKS +// TagResource). +func (m *Mock) TagResource(_ context.Context, arn string, tags map[string]string) error { + name := clusterNameFromARN(arn) + + c, ok := m.clusters.Get(name) + if !ok { + return cerrors.Newf(cerrors.NotFound, "cluster %q not found", name) + } + + if c.Tags == nil { + c.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + c.Tags[k] = v + } + + m.clusters.Set(name, c) + + return nil +} + +// UntagResource removes tags by key from a cluster identified by ARN. +func (m *Mock) UntagResource(_ context.Context, arn string, keys []string) error { + name := clusterNameFromARN(arn) + + c, ok := m.clusters.Get(name) + if !ok { + return cerrors.Newf(cerrors.NotFound, "cluster %q not found", name) + } + + for _, k := range keys { + delete(c.Tags, k) + } + + m.clusters.Set(name, c) + + return nil +} + +// ListResourceTags returns the tags on a cluster identified by ARN. +func (m *Mock) ListResourceTags(_ context.Context, arn string) (map[string]string, error) { + name := clusterNameFromARN(arn) + + c, ok := m.clusters.Get(name) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "cluster %q not found", name) + } + + out := make(map[string]string, len(c.Tags)) + for k, v := range c.Tags { + out[k] = v + } + + return out, nil +} diff --git a/providers/aws/elasticache/elasticache.go b/providers/aws/elasticache/elasticache.go index 33ce9007..9adc32f6 100644 --- a/providers/aws/elasticache/elasticache.go +++ b/providers/aws/elasticache/elasticache.go @@ -128,6 +128,29 @@ func (m *Mock) CreateCache(_ context.Context, cfg driver.CacheConfig) (*driver.C return &result, nil } +// ModifyCache updates the mutable fields (node type, engine) of an existing +// cache cluster (ElastiCache ModifyCacheCluster). Empty arguments leave the +// corresponding field unchanged. +func (m *Mock) ModifyCache(_ context.Context, name, nodeType, engine string) (*driver.CacheInfo, error) { + cd, ok := m.caches.Get(name) + if !ok { + return nil, errors.Newf(errors.NotFound, "cache %q not found", name) + } + + if nodeType != "" { + cd.info.NodeType = nodeType + } + if engine != "" { + cd.info.Engine = engine + } + + m.caches.Set(name, cd) + + result := cd.info + + return &result, nil +} + // DeleteCache deletes an ElastiCache cluster by name. func (m *Mock) DeleteCache(_ context.Context, name string) error { if !m.caches.Delete(name) { diff --git a/providers/aws/elb/tags.go b/providers/aws/elb/tags.go new file mode 100644 index 00000000..1e9f38ed --- /dev/null +++ b/providers/aws/elb/tags.go @@ -0,0 +1,60 @@ +package elb + +import "context" + +// AddResourceTags adds or overwrites tags on a load balancer or target group +// identified by ARN (ELBv2 AddTags). Unknown ARNs are ignored, matching AWS's +// tolerance for a mixed multi-resource AddTags call. +func (m *Mock) AddResourceTags(_ context.Context, arn string, tags map[string]string) error { + if lb, ok := m.lbs.Get(arn); ok { + if lb.Tags == nil { + lb.Tags = map[string]string{} + } + + for k, v := range tags { + lb.Tags[k] = v + } + + m.lbs.Set(arn, lb) + + return nil + } + + if tg, ok := m.tgs.Get(arn); ok { + if tg.Tags == nil { + tg.Tags = map[string]string{} + } + + for k, v := range tags { + tg.Tags[k] = v + } + + m.tgs.Set(arn, tg) + } + + return nil +} + +// RemoveResourceTags removes tags by key from a load balancer or target group +// identified by ARN (ELBv2 RemoveTags). +func (m *Mock) RemoveResourceTags(_ context.Context, arn string, keys []string) error { + if lb, ok := m.lbs.Get(arn); ok { + for _, k := range keys { + delete(lb.Tags, k) + } + + m.lbs.Set(arn, lb) + + return nil + } + + if tg, ok := m.tgs.Get(arn); ok { + for _, k := range keys { + delete(tg.Tags, k) + } + + m.tgs.Set(arn, tg) + } + + return nil +} diff --git a/providers/aws/eventbridge/delivery_test.go b/providers/aws/eventbridge/delivery_test.go new file mode 100644 index 00000000..f8d7ed36 --- /dev/null +++ b/providers/aws/eventbridge/delivery_test.go @@ -0,0 +1,63 @@ +package eventbridge_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + ebprovider "github.com/stackshy/cloudemu/v2/providers/aws/eventbridge" + sqsprovider "github.com/stackshy/cloudemu/v2/providers/aws/sqs" + ebdriver "github.com/stackshy/cloudemu/v2/services/eventbus/driver" + mqdriver "github.com/stackshy/cloudemu/v2/services/messagequeue/driver" +) + +func TestEventBridgeToSQSDelivery(t *testing.T) { + ctx := context.Background() + opts := config.NewOptions() + + sqs := sqsprovider.New(opts) + eb := ebprovider.New(opts) + eb.SetSQSDeliverer(sqs) + + q, err := sqs.CreateQueue(ctx, mqdriver.QueueConfig{Name: "eb-target"}) + if err != nil { + t.Fatalf("CreateQueue: %v", err) + } + + if _, err := eb.PutRule(ctx, &ebdriver.RuleConfig{ + Name: "r-all", EventPattern: `{"source":["myapp"]}`, + }); err != nil { + t.Fatalf("PutRule: %v", err) + } + + if err := eb.PutTargets(ctx, "", "r-all", []ebdriver.Target{ + {ID: "1", ARN: q.ARN}, + }); err != nil { + t.Fatalf("PutTargets: %v", err) + } + + if _, err := eb.PutEvents(ctx, []ebdriver.Event{ + {Source: "myapp", DetailType: "order.created", Detail: `{"orderId":"42"}`}, + }); err != nil { + t.Fatalf("PutEvents: %v", err) + } + + msgs, err := sqs.ReceiveMessages(ctx, mqdriver.ReceiveMessageInput{QueueURL: q.URL, MaxMessages: 10}) + if err != nil { + t.Fatalf("ReceiveMessages: %v", err) + } + + if len(msgs) != 1 { + t.Fatalf("expected 1 delivered event, got %d", len(msgs)) + } + + var env map[string]any + if err := json.Unmarshal([]byte(msgs[0].Body), &env); err != nil { + t.Fatalf("body not JSON: %v (%s)", err, msgs[0].Body) + } + + if env["detail-type"] != "order.created" || env["source"] != "myapp" { + t.Fatalf("unexpected envelope: %+v", env) + } +} diff --git a/providers/aws/eventbridge/eventbridge.go b/providers/aws/eventbridge/eventbridge.go index b4ee2b0a..f6958921 100644 --- a/providers/aws/eventbridge/eventbridge.go +++ b/providers/aws/eventbridge/eventbridge.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "maps" + "strings" "sync" "time" @@ -41,11 +42,18 @@ type busData struct { events []driver.Event } +// SQSDeliverer delivers an event to an SQS queue identified by its ARN. +type SQSDeliverer interface { + DeliverExternal(ctx context.Context, queueARN, body string) error +} + // Mock is an in-memory mock implementation of AWS EventBridge. type Mock struct { buses *memstore.Store[*busData] opts *config.Options monitoring mondriver.Monitoring + sqs SQSDeliverer + tagsByARN tagStore } // SetMonitoring sets the monitoring backend for auto-metric generation. @@ -53,6 +61,11 @@ func (m *Mock) SetMonitoring(mon mondriver.Monitoring) { m.monitoring = mon } +// SetSQSDeliverer wires the SQS backend so PutEvents delivers to SQS targets. +func (m *Mock) SetSQSDeliverer(d SQSDeliverer) { + m.sqs = d +} + func (m *Mock) emitMetric(metricName string, value float64, dims map[string]string) { if m.monitoring == nil { return @@ -391,13 +404,13 @@ func (m *Mock) ListTargets(_ context.Context, eventBus, ruleName string) ([]driv } // PutEvents publishes events to the event bus. -func (m *Mock) PutEvents(_ context.Context, events []driver.Event) (*driver.PublishResult, error) { +func (m *Mock) PutEvents(ctx context.Context, events []driver.Event) (*driver.PublishResult, error) { result := &driver.PublishResult{ EventIDs: make([]string, 0, len(events)), } for i := range events { - eventID := generateEventID(&events[i], m.opts.Clock.Now()) + eventID := generateEventID(&events[i], m.opts.Clock.Now(), i) events[i].ID = eventID if events[i].Time.IsZero() { @@ -418,6 +431,7 @@ func (m *Mock) PutEvents(_ context.Context, events []driver.Event) (*driver.Publ m.storeEvent(bd, &events[i]) matched := m.MatchedRules(&events[i]) + m.deliverToTargets(ctx, matched, &events[i]) dims := map[string]string{"EventBusName": busName} m.emitMetric("PutEventsRequestCount", 1, dims) @@ -430,6 +444,44 @@ func (m *Mock) PutEvents(_ context.Context, events []driver.Event) (*driver.Publ return result, nil } +// deliverToTargets delivers an event to the SQS targets of matched rules, +// wrapping it in the standard EventBridge event envelope. +func (m *Mock) deliverToTargets(ctx context.Context, matched []driver.Rule, event *driver.Event) { + if m.sqs == nil { + return + } + + for i := range matched { + for _, t := range matched[i].Targets { + if t.ARN == "" || !strings.Contains(t.ARN, ":sqs:") { + continue + } + + detail := json.RawMessage(event.Detail) + if len(detail) == 0 { + detail = json.RawMessage("{}") + } + + body, err := json.Marshal(map[string]any{ + "version": "0", + "id": event.ID, + "detail-type": event.DetailType, + "source": event.Source, + "account": m.opts.AccountID, + "time": event.Time.UTC().Format(time.RFC3339), + "region": m.opts.Region, + "resources": event.Resources, + "detail": detail, + }) + if err != nil { + continue + } + + _ = m.sqs.DeliverExternal(ctx, t.ARN, string(body)) + } + } +} + // GetEventHistory retrieves event history for an event bus. func (m *Mock) GetEventHistory(_ context.Context, eventBus string, limit int) ([]driver.Event, error) { busName := eventBus @@ -477,8 +529,14 @@ func targetsFromStore(store *memstore.Store[driver.Target]) []driver.Target { return targets } -func generateEventID(event *driver.Event, now time.Time) string { - data := fmt.Sprintf("%s:%s:%s:%s:%d", event.Source, event.DetailType, event.Detail, event.EventBus, now.UnixNano()) +// generateEventID hashes the event's identity plus the clock and its position +// within the PutEvents batch. The batch index is included because real +// EventBridge always issues unique IDs, and under a deterministic (fake) clock +// two byte-identical events in one call would otherwise collide — breaking any +// consumer that uses EventId as an idempotency/history key. +func generateEventID(event *driver.Event, now time.Time, index int) string { + data := fmt.Sprintf("%s:%s:%s:%s:%d:%d", + event.Source, event.DetailType, event.Detail, event.EventBus, now.UnixNano(), index) hash := sha256.Sum256([]byte(data)) return fmt.Sprintf("%x", hash[:16]) diff --git a/providers/aws/eventbridge/eventbridge_test.go b/providers/aws/eventbridge/eventbridge_test.go index 51f47d93..d67e6001 100644 --- a/providers/aws/eventbridge/eventbridge_test.go +++ b/providers/aws/eventbridge/eventbridge_test.go @@ -559,6 +559,19 @@ func TestPutEvents(t *testing.T) { assert.Equal(t, 1, result.SuccessCount) assert.Equal(t, 1, result.FailCount) }) + + t.Run("byte-identical events in one call get unique ids", func(t *testing.T) { + // Under the deterministic FakeClock the timestamp is identical, so the + // batch index must keep the ids distinct — real EventBridge never + // repeats an EventId, and consumers use it as an idempotency key. + result, err := m.PutEvents(ctx, []driver.Event{ + {Source: "dup.app", DetailType: "Same", Detail: `{"k":"v"}`}, + {Source: "dup.app", DetailType: "Same", Detail: `{"k":"v"}`}, + }) + require.NoError(t, err) + require.Len(t, result.EventIDs, 2) + assert.NotEqual(t, result.EventIDs[0], result.EventIDs[1], "identical events must get distinct EventIds") + }) } func TestEventPatternMatching(t *testing.T) { @@ -771,3 +784,34 @@ func TestMetricsEmission(t *testing.T) { assert.Contains(t, metrics, "MatchedEvents") }) } + +// TestResourceTagging is a regression guard for issue #319: EventBridge +// TagResource/UntagResource/ListTagsForResource were unimplemented. +func TestResourceTagging(t *testing.T) { + m, _ := newTestMock() + ctx := context.Background() + + arn := "arn:aws:events:us-east-1:000000000000:rule/r1" + + if err := m.TagResource(ctx, arn, map[string]string{"env": "prod", "team": "evt"}); err != nil { + t.Fatalf("TagResource: %v", err) + } + + tags, err := m.ListResourceTags(ctx, arn) + if err != nil { + t.Fatalf("ListResourceTags: %v", err) + } + + if tags["env"] != "prod" || tags["team"] != "evt" { + t.Fatalf("tags = %v", tags) + } + + if err := m.UntagResource(ctx, arn, []string{"env"}); err != nil { + t.Fatalf("UntagResource: %v", err) + } + + tags, _ = m.ListResourceTags(ctx, arn) + if _, has := tags["env"]; has || tags["team"] != "evt" { + t.Fatalf("after untag = %v", tags) + } +} diff --git a/providers/aws/eventbridge/tags.go b/providers/aws/eventbridge/tags.go new file mode 100644 index 00000000..98b2ab73 --- /dev/null +++ b/providers/aws/eventbridge/tags.go @@ -0,0 +1,71 @@ +package eventbridge + +import ( + "context" + "sync" +) + +// tagStore is a generic ARN-keyed tag store. EventBridge tags rules and event +// buses by ARN; rules carry no tag field of their own, so a shared store keyed +// by ARN backs TagResource/UntagResource/ListTagsForResource uniformly. +type tagStore struct { + mu sync.RWMutex + tags map[string]map[string]string // ARN -> tags +} + +func (t *tagStore) tag(arn string, tags map[string]string) { + t.mu.Lock() + defer t.mu.Unlock() + + if t.tags == nil { + t.tags = map[string]map[string]string{} + } + + if t.tags[arn] == nil { + t.tags[arn] = map[string]string{} + } + + for k, v := range tags { + t.tags[arn][k] = v + } +} + +func (t *tagStore) untag(arn string, keys []string) { + t.mu.Lock() + defer t.mu.Unlock() + + for _, k := range keys { + delete(t.tags[arn], k) + } +} + +func (t *tagStore) list(arn string) map[string]string { + t.mu.RLock() + defer t.mu.RUnlock() + + out := make(map[string]string, len(t.tags[arn])) + for k, v := range t.tags[arn] { + out[k] = v + } + + return out +} + +// TagResource tags an EventBridge resource (rule or event bus) by ARN. +func (m *Mock) TagResource(_ context.Context, arn string, tags map[string]string) error { + m.tagsByARN.tag(arn, tags) + + return nil +} + +// UntagResource removes tags by key from an EventBridge resource by ARN. +func (m *Mock) UntagResource(_ context.Context, arn string, keys []string) error { + m.tagsByARN.untag(arn, keys) + + return nil +} + +// ListResourceTags returns the tags on an EventBridge resource by ARN. +func (m *Mock) ListResourceTags(_ context.Context, arn string) (map[string]string, error) { + return m.tagsByARN.list(arn), nil +} diff --git a/providers/aws/lambda/lambda.go b/providers/aws/lambda/lambda.go index d077f573..ba254518 100644 --- a/providers/aws/lambda/lambda.go +++ b/providers/aws/lambda/lambda.go @@ -51,6 +51,7 @@ type funcData struct { nextVersion int aliases *memstore.Store[*aliasData] concurrency *driver.ConcurrencyConfig + policy map[string]driver.PermissionStatement } // Mock is an in-memory mock implementation of AWS Lambda. @@ -190,10 +191,21 @@ func (m *Mock) Invoke(ctx context.Context, input driver.InvokeInput) (*driver.In } if h == nil { + // The emulator can't execute an uploaded zip (arbitrary Python/Node/ + // etc.), so with no Go handler registered we return a successful stub + // that echoes the request payload rather than a FunctionError. This + // lets users exercise invoke control flow (wiring, permissions, + // event-source mappings) without a real runtime. Register a handler via + // RegisterHandler to run real logic. m.emitMetric(ctx, "Invocations", 1, dims) - m.emitMetric(ctx, "Errors", 1, dims) + m.emitMetric(ctx, "Duration", 1.0, dims) + + payload := input.Payload + if len(payload) == 0 { + payload = []byte("{}") + } - return &driver.InvokeOutput{StatusCode: 500, Error: "no handler registered"}, nil + return &driver.InvokeOutput{StatusCode: 200, Payload: payload}, nil } payload, err := h(ctx, input.Payload) diff --git a/providers/aws/lambda/lambda_test.go b/providers/aws/lambda/lambda_test.go index b3308a9b..5ef18cd1 100644 --- a/providers/aws/lambda/lambda_test.go +++ b/providers/aws/lambda/lambda_test.go @@ -164,14 +164,18 @@ func TestInvokeFunction(t *testing.T) { ctx := context.Background() _, _ = m.CreateFunction(ctx, defaultFuncConfig()) - t.Run("no handler returns error", func(t *testing.T) { + t.Run("no handler echoes a success stub", func(t *testing.T) { + // The emulator can't run an uploaded zip, so with no Go handler it + // returns a 200 stub echoing the payload rather than a FunctionError + // (issue #319) — invoke stays testable. out, err := m.Invoke(ctx, driver.InvokeInput{ FunctionName: "my-func", - Payload: []byte("test"), + Payload: []byte(`{"k":1}`), }) requireNoError(t, err) - assertEqual(t, 500, out.StatusCode) - assertEqual(t, "no handler registered", out.Error) + assertEqual(t, 200, out.StatusCode) + assertEqual(t, "", out.Error) + assertEqual(t, `{"k":1}`, string(out.Payload)) }) t.Run("with handler success", func(t *testing.T) { diff --git a/providers/aws/lambda/policy.go b/providers/aws/lambda/policy.go new file mode 100644 index 00000000..7579f3a7 --- /dev/null +++ b/providers/aws/lambda/policy.go @@ -0,0 +1,106 @@ +package lambda + +import ( + "context" + "encoding/json" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +// AddPermission adds a statement to a function's resource-based policy. This +// backs Terraform's aws_lambda_permission and the grants S3/SNS/EventBridge +// create to invoke a function. The emulator stores statements without +// evaluating them — invocation is never actually denied. +func (m *Mock) AddPermission(_ context.Context, functionName string, stmt driver.PermissionStatement) error { + if stmt.StatementID == "" { + return cerrors.New(cerrors.InvalidArgument, "StatementId is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + fd, ok := m.funcs.Get(functionName) + if !ok { + return cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + } + + if fd.policy == nil { + fd.policy = make(map[string]driver.PermissionStatement) + } + + if _, exists := fd.policy[stmt.StatementID]; exists { + return cerrors.Newf(cerrors.AlreadyExists, "statement %s already exists", stmt.StatementID) + } + + fd.policy[stmt.StatementID] = stmt + m.funcs.Set(functionName, fd) + + return nil +} + +// RemovePermission drops a statement from a function's resource-based policy. +func (m *Mock) RemovePermission(_ context.Context, functionName, statementID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + fd, ok := m.funcs.Get(functionName) + if !ok { + return cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + } + + if _, exists := fd.policy[statementID]; !exists { + return cerrors.Newf(cerrors.NotFound, "statement %s not found", statementID) + } + + delete(fd.policy, statementID) + m.funcs.Set(functionName, fd) + + return nil +} + +// GetPolicy returns the function's resource-based policy as a JSON document, +// matching the shape the AWS SDK expects (IAM policy with Sid/Principal/ +// Action/Resource per statement). Returns NotFound when no policy exists. +func (m *Mock) GetPolicy(_ context.Context, functionName string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + fd, ok := m.funcs.Get(functionName) + if !ok { + return "", cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + } + + if len(fd.policy) == 0 { + return "", cerrors.Newf(cerrors.NotFound, "no policy for function %s", functionName) + } + + statements := make([]map[string]any, 0, len(fd.policy)) + for _, s := range fd.policy { + stmt := map[string]any{ + "Sid": s.StatementID, + "Effect": "Allow", + "Principal": map[string]string{"Service": s.Principal}, + "Action": s.Action, + "Resource": fd.info.ARN, + } + if s.SourceARN != "" { + stmt["Condition"] = map[string]any{ + "ArnLike": map[string]string{"AWS:SourceArn": s.SourceARN}, + } + } + + statements = append(statements, stmt) + } + + doc, err := json.Marshal(map[string]any{ + "Version": "2012-10-17", + "Id": "default", + "Statement": statements, + }) + if err != nil { + return "", err + } + + return string(doc), nil +} diff --git a/providers/aws/lambda/tags.go b/providers/aws/lambda/tags.go new file mode 100644 index 00000000..2e38c35d --- /dev/null +++ b/providers/aws/lambda/tags.go @@ -0,0 +1,60 @@ +package lambda + +import ( + "context" + "maps" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// TagFunction adds or overwrites tags on a function (Lambda TagResource). +func (m *Mock) TagFunction(_ context.Context, name string, tags map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + fd, ok := m.funcs.Get(name) + if !ok { + return cerrors.Newf(cerrors.NotFound, "function %s not found", name) + } + + if fd.info.Tags == nil { + fd.info.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + fd.info.Tags[k] = v + } + + m.funcs.Set(name, fd) + + return nil +} + +// UntagFunction removes tags by key from a function (Lambda UntagResource). +func (m *Mock) UntagFunction(_ context.Context, name string, keys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + fd, ok := m.funcs.Get(name) + if !ok { + return cerrors.Newf(cerrors.NotFound, "function %s not found", name) + } + + for _, k := range keys { + delete(fd.info.Tags, k) + } + + m.funcs.Set(name, fd) + + return nil +} + +// ListFunctionTags returns a function's tags (Lambda ListTags). +func (m *Mock) ListFunctionTags(_ context.Context, name string) (map[string]string, error) { + fd, ok := m.funcs.Get(name) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", name) + } + + return maps.Clone(fd.info.Tags), nil +} diff --git a/providers/aws/networkfirewall/networkfirewall.go b/providers/aws/networkfirewall/networkfirewall.go new file mode 100644 index 00000000..055dc6c8 --- /dev/null +++ b/providers/aws/networkfirewall/networkfirewall.go @@ -0,0 +1,613 @@ +// Package networkfirewall provides an in-memory mock of AWS Network Firewall. +package networkfirewall + +import ( + "context" + "fmt" + "sync" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/internal/memstore" + nfdriver "github.com/stackshy/cloudemu/v2/services/networkfirewall/driver" +) + +const ( + ruleTypeStateful = "STATEFUL" + ruleTypeStateless = "STATELESS" +) + +var _ nfdriver.NetworkFirewall = (*Mock)(nil) + +// Mock is the in-memory AWS Network Firewall implementation. Resources are +// keyed by name (unique per account/region, as in the real service). +// +// The stores hand back shared pointers whose fields are mutated in place +// (associate/tag/protection/subnets) and logging is a plain map, so every +// public method serializes on mu: mutators take Lock, readers take RLock. +type Mock struct { + mu sync.RWMutex + firewalls *memstore.Store[*nfdriver.Firewall] + policies *memstore.Store[*nfdriver.FirewallPolicy] + ruleGroups *memstore.Store[*nfdriver.RuleGroup] + logging map[string][]string + opts *config.Options +} + +// New creates a new Network Firewall mock. +func New(opts *config.Options) *Mock { + return &Mock{ + firewalls: memstore.New[*nfdriver.Firewall](), + policies: memstore.New[*nfdriver.FirewallPolicy](), + ruleGroups: memstore.New[*nfdriver.RuleGroup](), + logging: map[string][]string{}, + opts: opts, + } +} + +func (m *Mock) arn(kind, name string) string { + return idgen.AWSARN("network-firewall", m.opts.Region, m.opts.AccountID, kind+"/"+name) +} + +func copyTags(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + + out := make(map[string]string, len(src)) + for k, v := range src { + out[k] = v + } + + return out +} + +func cloneStrings(s []string) []string { + if len(s) == 0 { + return nil + } + + return append([]string(nil), s...) +} + +// ---- Firewalls ---- + +//nolint:gocritic // cfg matches the driver signature. +func (m *Mock) CreateFirewall(_ context.Context, cfg nfdriver.CreateFirewallConfig) (*nfdriver.Firewall, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "FirewallName is required") + } + + if m.firewalls.Has(cfg.Name) { + return nil, cerrors.Newf(cerrors.AlreadyExists, "firewall %q already exists", cfg.Name) + } + + if cfg.PolicyARN != "" && !m.policyExists(cfg.PolicyARN) { + return nil, cerrors.Newf(cerrors.InvalidArgument, "firewall policy %q not found", cfg.PolicyARN) + } + + fw := &nfdriver.Firewall{ + Name: cfg.Name, + ARN: m.arn("firewall", cfg.Name), + PolicyARN: cfg.PolicyARN, + VPCID: cfg.VPCID, + SubnetIDs: cloneStrings(cfg.SubnetIDs), + Description: cfg.Description, + DeleteProtection: cfg.DeleteProtection, + Status: "READY", + Tags: copyTags(cfg.Tags), + } + m.firewalls.Set(cfg.Name, fw) + + out := cloneFirewall(fw) + + return &out, nil +} + +func (m *Mock) DescribeFirewall(_ context.Context, name, arn string) (*nfdriver.Firewall, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + fw, ok := m.lookupFirewall(name, arn) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "firewall %q not found", nonEmpty(name, arn)) + } + + out := cloneFirewall(fw) + + return &out, nil +} + +func (m *Mock) DeleteFirewall(_ context.Context, name, arn string) (*nfdriver.Firewall, error) { + m.mu.Lock() + defer m.mu.Unlock() + + fw, ok := m.lookupFirewall(name, arn) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "firewall %q not found", nonEmpty(name, arn)) + } + + if fw.DeleteProtection { + return nil, cerrors.Newf(cerrors.FailedPrecondition, "firewall %q has delete protection enabled", fw.Name) + } + + fw.Status = "DELETING" + m.firewalls.Delete(fw.Name) + + out := cloneFirewall(fw) + + return &out, nil +} + +func (m *Mock) ListFirewalls(_ context.Context) ([]nfdriver.Firewall, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + all := m.firewalls.SortedValues() + out := make([]nfdriver.Firewall, 0, len(all)) + + for _, f := range all { + out = append(out, cloneFirewall(f)) + } + + return out, nil +} + +func (m *Mock) lookupFirewall(name, arn string) (*nfdriver.Firewall, bool) { + if name != "" { + return m.firewalls.Get(name) + } + + for _, f := range m.firewalls.SortedValues() { + if f.ARN == arn { + return f, true + } + } + + return nil, false +} + +func cloneFirewall(f *nfdriver.Firewall) nfdriver.Firewall { + out := *f + out.SubnetIDs = cloneStrings(f.SubnetIDs) + out.Tags = copyTags(f.Tags) + + return out +} + +// ---- Firewall Policies ---- + +//nolint:gocritic // cfg matches the driver signature. +func (m *Mock) CreateFirewallPolicy(_ context.Context, cfg nfdriver.CreateFirewallPolicyConfig) (*nfdriver.FirewallPolicy, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "FirewallPolicyName is required") + } + + if m.policies.Has(cfg.Name) { + return nil, cerrors.Newf(cerrors.AlreadyExists, "firewall policy %q already exists", cfg.Name) + } + + p := &nfdriver.FirewallPolicy{ + Name: cfg.Name, + ARN: m.arn("firewall-policy", cfg.Name), + ID: idgen.GenerateID(""), + Description: cfg.Description, + StatelessDefaultActions: cloneStrings(cfg.StatelessDefaultActions), + StatelessFragmentDefaultActions: cloneStrings(cfg.StatelessFragmentDefaultActions), + Tags: copyTags(cfg.Tags), + } + m.policies.Set(cfg.Name, p) + + out := cloneFirewallPolicy(p) + + return &out, nil +} + +func (m *Mock) DescribeFirewallPolicy(_ context.Context, name, arn string) (*nfdriver.FirewallPolicy, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + p, ok := m.lookupPolicy(name, arn) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "firewall policy %q not found", nonEmpty(name, arn)) + } + + out := cloneFirewallPolicy(p) + + return &out, nil +} + +func (m *Mock) DeleteFirewallPolicy(_ context.Context, name, arn string) (*nfdriver.FirewallPolicy, error) { + m.mu.Lock() + defer m.mu.Unlock() + + p, ok := m.lookupPolicy(name, arn) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "firewall policy %q not found", nonEmpty(name, arn)) + } + + if m.policyInUse(p.ARN) { + return nil, cerrors.Newf(cerrors.FailedPrecondition, "firewall policy %q is in use by a firewall", p.Name) + } + + m.policies.Delete(p.Name) + + out := cloneFirewallPolicy(p) + + return &out, nil +} + +func (m *Mock) ListFirewallPolicies(_ context.Context) ([]nfdriver.FirewallPolicy, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + all := m.policies.SortedValues() + out := make([]nfdriver.FirewallPolicy, 0, len(all)) + + for _, p := range all { + out = append(out, cloneFirewallPolicy(p)) + } + + return out, nil +} + +func (m *Mock) lookupPolicy(name, arn string) (*nfdriver.FirewallPolicy, bool) { + if name != "" { + return m.policies.Get(name) + } + + for _, p := range m.policies.SortedValues() { + if p.ARN == arn { + return p, true + } + } + + return nil, false +} + +// policyExists reports whether a firewall policy with the given ARN is present. +func (m *Mock) policyExists(arn string) bool { + for _, p := range m.policies.SortedValues() { + if p.ARN == arn { + return true + } + } + + return false +} + +// policyInUse reports whether any firewall references the policy ARN. +func (m *Mock) policyInUse(arn string) bool { + for _, f := range m.firewalls.SortedValues() { + if f.PolicyARN == arn { + return true + } + } + + return false +} + +func cloneFirewallPolicy(p *nfdriver.FirewallPolicy) nfdriver.FirewallPolicy { + out := *p + out.StatelessDefaultActions = cloneStrings(p.StatelessDefaultActions) + out.StatelessFragmentDefaultActions = cloneStrings(p.StatelessFragmentDefaultActions) + out.Tags = copyTags(p.Tags) + + return out +} + +// ---- Rule Groups ---- + +func (m *Mock) CreateRuleGroup(_ context.Context, cfg nfdriver.CreateRuleGroupConfig) (*nfdriver.RuleGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "RuleGroupName is required") + } + + if cfg.Type != ruleTypeStateful && cfg.Type != ruleTypeStateless { + return nil, cerrors.New(cerrors.InvalidArgument, "Type must be STATEFUL or STATELESS") + } + + key := ruleGroupKey(cfg.Name, cfg.Type) + if m.ruleGroups.Has(key) { + return nil, cerrors.Newf(cerrors.AlreadyExists, "rule group %q already exists", cfg.Name) + } + + rgKind := "stateful-rulegroup" + if cfg.Type == ruleTypeStateless { + rgKind = "stateless-rulegroup" + } + + rg := &nfdriver.RuleGroup{ + Name: cfg.Name, + ARN: m.arn(rgKind, cfg.Name), + ID: idgen.GenerateID(""), + Type: cfg.Type, + Capacity: cfg.Capacity, + Description: cfg.Description, + Tags: copyTags(cfg.Tags), + } + m.ruleGroups.Set(key, rg) + + out := cloneRuleGroup(rg) + + return &out, nil +} + +func (m *Mock) DescribeRuleGroup(_ context.Context, name, arn, ruleType string) (*nfdriver.RuleGroup, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + rg, ok := m.lookupRuleGroup(name, arn, ruleType) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "rule group %q not found", nonEmpty(name, arn)) + } + + out := cloneRuleGroup(rg) + + return &out, nil +} + +func (m *Mock) DeleteRuleGroup(_ context.Context, name, arn, ruleType string) (*nfdriver.RuleGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rg, ok := m.lookupRuleGroup(name, arn, ruleType) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "rule group %q not found", nonEmpty(name, arn)) + } + + m.ruleGroups.Delete(ruleGroupKey(rg.Name, rg.Type)) + + out := cloneRuleGroup(rg) + + return &out, nil +} + +func (m *Mock) ListRuleGroups(_ context.Context) ([]nfdriver.RuleGroup, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + all := m.ruleGroups.SortedValues() + out := make([]nfdriver.RuleGroup, 0, len(all)) + + for _, rg := range all { + out = append(out, cloneRuleGroup(rg)) + } + + return out, nil +} + +func (m *Mock) lookupRuleGroup(name, arn, ruleType string) (*nfdriver.RuleGroup, bool) { + if name != "" && ruleType != "" { + return m.ruleGroups.Get(ruleGroupKey(name, ruleType)) + } + + for _, rg := range m.ruleGroups.SortedValues() { + if (name != "" && rg.Name == name) || (arn != "" && rg.ARN == arn) { + return rg, true + } + } + + return nil, false +} + +func ruleGroupKey(name, ruleType string) string { + return fmt.Sprintf("%s/%s", ruleType, name) +} + +func cloneRuleGroup(rg *nfdriver.RuleGroup) nfdriver.RuleGroup { + out := *rg + out.Tags = copyTags(rg.Tags) + + return out +} + +func nonEmpty(a, b string) string { + if a != "" { + return a + } + + return b +} + +// ---- Firewall depth: associations, protection, logging, tags ---- + +// AssociateFirewallPolicy attaches a firewall policy to a firewall. +func (m *Mock) AssociateFirewallPolicy(_ context.Context, firewallName, policyARN string) (*nfdriver.Firewall, error) { + m.mu.Lock() + defer m.mu.Unlock() + + fw, ok := m.firewalls.Get(firewallName) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "firewall %q not found", firewallName) + } + + if !m.policyExists(policyARN) { + return nil, cerrors.Newf(cerrors.InvalidArgument, "firewall policy %q not found", policyARN) + } + + fw.PolicyARN = policyARN + + out := cloneFirewall(fw) + + return &out, nil +} + +// AssociateSubnets adds subnet mappings to a firewall. +func (m *Mock) AssociateSubnets(_ context.Context, firewallName string, subnetIDs []string) (*nfdriver.Firewall, error) { + m.mu.Lock() + defer m.mu.Unlock() + + fw, ok := m.firewalls.Get(firewallName) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "firewall %q not found", firewallName) + } + + seen := make(map[string]bool, len(fw.SubnetIDs)) + for _, s := range fw.SubnetIDs { + seen[s] = true + } + + for _, s := range subnetIDs { + if !seen[s] { + fw.SubnetIDs = append(fw.SubnetIDs, s) + seen[s] = true + } + } + + out := cloneFirewall(fw) + + return &out, nil +} + +// DisassociateSubnets removes subnet mappings from a firewall. +func (m *Mock) DisassociateSubnets(_ context.Context, firewallName string, subnetIDs []string) (*nfdriver.Firewall, error) { + m.mu.Lock() + defer m.mu.Unlock() + + fw, ok := m.firewalls.Get(firewallName) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "firewall %q not found", firewallName) + } + + remove := make(map[string]bool, len(subnetIDs)) + for _, s := range subnetIDs { + remove[s] = true + } + + kept := fw.SubnetIDs[:0:0] + + for _, s := range fw.SubnetIDs { + if !remove[s] { + kept = append(kept, s) + } + } + + fw.SubnetIDs = kept + + out := cloneFirewall(fw) + + return &out, nil +} + +// UpdateFirewallDeleteProtection toggles delete protection on a firewall. +func (m *Mock) UpdateFirewallDeleteProtection(_ context.Context, firewallName string, enabled bool) (*nfdriver.Firewall, error) { + m.mu.Lock() + defer m.mu.Unlock() + + fw, ok := m.firewalls.Get(firewallName) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "firewall %q not found", firewallName) + } + + fw.DeleteProtection = enabled + + out := cloneFirewall(fw) + + return &out, nil +} + +// UpdateLoggingConfiguration sets the firewall's log types. +func (m *Mock) UpdateLoggingConfiguration(_ context.Context, firewallName string, logTypes []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.firewalls.Has(firewallName) { + return cerrors.Newf(cerrors.NotFound, "firewall %q not found", firewallName) + } + + m.logging[firewallName] = append([]string(nil), logTypes...) + + return nil +} + +// DescribeLoggingConfiguration returns the firewall's log types. +func (m *Mock) DescribeLoggingConfiguration(_ context.Context, firewallName string) ([]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.firewalls.Has(firewallName) { + return nil, cerrors.Newf(cerrors.NotFound, "firewall %q not found", firewallName) + } + + return append([]string(nil), m.logging[firewallName]...), nil +} + +// TagResource adds tags to a firewall / policy / rule group by ARN. +func (m *Mock) TagResource(_ context.Context, arn string, tags map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if t := m.taggableByARN(arn); t != nil { + for k, v := range tags { + t[k] = v + } + + return nil + } + + return cerrors.Newf(cerrors.NotFound, "resource %q not found", arn) +} + +// UntagResource removes tags from a firewall / policy / rule group by ARN. +func (m *Mock) UntagResource(_ context.Context, arn string, keys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + t := m.taggableByARN(arn) + if t == nil { + return cerrors.Newf(cerrors.NotFound, "resource %q not found", arn) + } + + for _, k := range keys { + delete(t, k) + } + + return nil +} + +// taggableByARN returns the mutable Tags map of the resource with the given ARN, +// creating it if nil, or nil when no resource matches. +func (m *Mock) taggableByARN(arn string) map[string]string { + for _, f := range m.firewalls.SortedValues() { + if f.ARN == arn { + if f.Tags == nil { + f.Tags = map[string]string{} + } + + return f.Tags + } + } + + for _, p := range m.policies.SortedValues() { + if p.ARN == arn { + if p.Tags == nil { + p.Tags = map[string]string{} + } + + return p.Tags + } + } + + for _, rg := range m.ruleGroups.SortedValues() { + if rg.ARN == arn { + if rg.Tags == nil { + rg.Tags = map[string]string{} + } + + return rg.Tags + } + } + + return nil +} diff --git a/providers/aws/networkfirewall/networkfirewall_test.go b/providers/aws/networkfirewall/networkfirewall_test.go new file mode 100644 index 00000000..3a446ab5 --- /dev/null +++ b/providers/aws/networkfirewall/networkfirewall_test.go @@ -0,0 +1,203 @@ +package networkfirewall + +import ( + "context" + "sync" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + nfdriver "github.com/stackshy/cloudemu/v2/services/networkfirewall/driver" +) + +func newMock() *Mock { return New(config.NewOptions()) } + +func TestFirewallLifecycle(t *testing.T) { + m := newMock() + ctx := context.Background() + + if _, err := m.CreateFirewall(ctx, nfdriver.CreateFirewallConfig{}); !cerrors.IsInvalidArgument(err) { + t.Fatalf("firewall without name: got %v, want InvalidArgument", err) + } + + fw, err := m.CreateFirewall(ctx, nfdriver.CreateFirewallConfig{ + Name: "fw-1", VPCID: "vpc-1", SubnetIDs: []string{"subnet-1"}, DeleteProtection: true, + }) + if err != nil || fw.ARN == "" || fw.Status != "READY" { + t.Fatalf("CreateFirewall: %v %+v", err, fw) + } + + if _, err := m.CreateFirewall(ctx, nfdriver.CreateFirewallConfig{Name: "fw-1"}); !cerrors.IsAlreadyExists(err) { + t.Fatalf("duplicate firewall: got %v, want AlreadyExists", err) + } + + // Delete protection blocks deletion. + if _, err := m.DeleteFirewall(ctx, "fw-1", ""); !cerrors.IsFailedPrecondition(err) { + t.Fatalf("delete protected firewall: got %v, want FailedPrecondition", err) + } + + // Lookup by ARN works too. + got, err := m.DescribeFirewall(ctx, "", fw.ARN) + if err != nil || got.Name != "fw-1" { + t.Fatalf("DescribeFirewall by ARN: %v %+v", err, got) + } + + list, _ := m.ListFirewalls(ctx) + if len(list) != 1 { + t.Fatalf("ListFirewalls: %+v", list) + } +} + +func TestFirewallPolicyAndRuleGroup(t *testing.T) { + m := newMock() + ctx := context.Background() + + pol, err := m.CreateFirewallPolicy(ctx, nfdriver.CreateFirewallPolicyConfig{ + Name: "pol-1", StatelessDefaultActions: []string{"aws:forward_to_sfe"}, + }) + if err != nil || pol.ID == "" { + t.Fatalf("CreateFirewallPolicy: %v %+v", err, pol) + } + + if _, err := m.DescribeFirewallPolicy(ctx, "pol-1", ""); err != nil { + t.Fatalf("DescribeFirewallPolicy: %v", err) + } + + if _, err := m.DeleteFirewallPolicy(ctx, "pol-1", ""); err != nil { + t.Fatalf("DeleteFirewallPolicy: %v", err) + } + + // Rule group: type is validated. + if _, err := m.CreateRuleGroup(ctx, nfdriver.CreateRuleGroupConfig{Name: "rg", Type: "BOGUS"}); !cerrors.IsInvalidArgument(err) { + t.Fatalf("bad rule group type: got %v, want InvalidArgument", err) + } + + rg, err := m.CreateRuleGroup(ctx, nfdriver.CreateRuleGroupConfig{Name: "rg-1", Type: "STATEFUL", Capacity: 100}) + if err != nil { + t.Fatalf("CreateRuleGroup: %v", err) + } + + got, err := m.DescribeRuleGroup(ctx, "rg-1", "", "STATEFUL") + if err != nil || got.Capacity != 100 { + t.Fatalf("DescribeRuleGroup: %v %+v", err, got) + } + + if _, err := m.DeleteRuleGroup(ctx, rg.Name, "", "STATEFUL"); err != nil { + t.Fatalf("DeleteRuleGroup: %v", err) + } + + if _, err := m.DescribeRuleGroup(ctx, "rg-1", "", "STATEFUL"); !cerrors.IsNotFound(err) { + t.Fatalf("describe deleted rule group: got %v, want NotFound", err) + } +} + +func TestFirewallDepth(t *testing.T) { + m := newMock() + ctx := context.Background() + + pol, err := m.CreateFirewallPolicy(ctx, nfdriver.CreateFirewallPolicyConfig{Name: "pol-dep"}) + if err != nil { + t.Fatalf("CreateFirewallPolicy: %v", err) + } + + fw, err := m.CreateFirewall(ctx, nfdriver.CreateFirewallConfig{Name: "fw-1", SubnetIDs: []string{"subnet-1"}}) + if err != nil { + t.Fatalf("CreateFirewall: %v", err) + } + + // Associating a nonexistent policy is rejected. + if _, err := m.AssociateFirewallPolicy(ctx, "fw-1", "arn:nope"); !cerrors.IsInvalidArgument(err) { + t.Fatalf("associate missing policy: got %v, want InvalidArgument", err) + } + + if _, err := m.AssociateFirewallPolicy(ctx, "fw-1", pol.ARN); err != nil { + t.Fatalf("AssociateFirewallPolicy: %v", err) + } + + // The policy is now in use, so deleting it is blocked. + if _, err := m.DeleteFirewallPolicy(ctx, "pol-dep", ""); !cerrors.IsFailedPrecondition(err) { + t.Fatalf("delete in-use policy: got %v, want FailedPrecondition", err) + } + + assoc, err := m.AssociateSubnets(ctx, "fw-1", []string{"subnet-1", "subnet-2"}) + if err != nil || len(assoc.SubnetIDs) != 2 { + t.Fatalf("AssociateSubnets: %v %+v", err, assoc) + } + + dis, err := m.DisassociateSubnets(ctx, "fw-1", []string{"subnet-1"}) + if err != nil || len(dis.SubnetIDs) != 1 || dis.SubnetIDs[0] != "subnet-2" { + t.Fatalf("DisassociateSubnets: %v %+v", err, dis) + } + + prot, err := m.UpdateFirewallDeleteProtection(ctx, "fw-1", true) + if err != nil || !prot.DeleteProtection { + t.Fatalf("UpdateFirewallDeleteProtection: %v %+v", err, prot) + } + + if err := m.UpdateLoggingConfiguration(ctx, "fw-1", []string{"FLOW", "ALERT"}); err != nil { + t.Fatalf("UpdateLoggingConfiguration: %v", err) + } + + logs, err := m.DescribeLoggingConfiguration(ctx, "fw-1") + if err != nil || len(logs) != 2 { + t.Fatalf("DescribeLoggingConfiguration: %v %+v", err, logs) + } + + if err := m.TagResource(ctx, fw.ARN, map[string]string{"env": "prod"}); err != nil { + t.Fatalf("TagResource: %v", err) + } + + got, _ := m.DescribeFirewall(ctx, "fw-1", "") + if got.Tags["env"] != "prod" { + t.Fatalf("tag not applied: %+v", got.Tags) + } + + if err := m.UntagResource(ctx, fw.ARN, []string{"env"}); err != nil { + t.Fatalf("UntagResource: %v", err) + } + + got, _ = m.DescribeFirewall(ctx, "fw-1", "") + if _, ok := got.Tags["env"]; ok { + t.Fatalf("tag not removed: %+v", got.Tags) + } + + // Unknown firewall / ARN error paths. + if _, err := m.AssociateFirewallPolicy(ctx, "missing", "x"); !cerrors.IsNotFound(err) { + t.Fatalf("associate missing firewall: got %v, want NotFound", err) + } + + if err := m.TagResource(ctx, "arn:nope", map[string]string{"a": "b"}); !cerrors.IsNotFound(err) { + t.Fatalf("tag unknown arn: got %v, want NotFound", err) + } +} + +// TestFirewallConcurrentAccess exercises the mutex under the race detector: +// concurrent logging writes + describes + tag mutations must not race or trip +// "concurrent map writes". Run with -race for it to be meaningful. +func TestFirewallConcurrentAccess(t *testing.T) { + m := newMock() + ctx := context.Background() + + if _, err := m.CreateFirewall(ctx, nfdriver.CreateFirewallConfig{Name: "fw-1"}); err != nil { + t.Fatalf("CreateFirewall: %v", err) + } + + fw, _ := m.DescribeFirewall(ctx, "fw-1", "") + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + + go func() { + defer wg.Done() + + _ = m.UpdateLoggingConfiguration(ctx, "fw-1", []string{"FLOW", "ALERT"}) + _, _ = m.DescribeLoggingConfiguration(ctx, "fw-1") + _, _ = m.DescribeFirewall(ctx, "fw-1", "") + _ = m.TagResource(ctx, fw.ARN, map[string]string{"k": "v"}) + _, _ = m.ListFirewalls(ctx) + }() + } + + wg.Wait() +} diff --git a/providers/aws/redshift/redshift.go b/providers/aws/redshift/redshift.go index fb064227..f247e61c 100644 --- a/providers/aws/redshift/redshift.go +++ b/providers/aws/redshift/redshift.go @@ -39,12 +39,30 @@ var errInstanceOpsUnsupported = cerrors.New(cerrors.InvalidArgument, var _ rdbdriver.RelationalDB = (*Mock)(nil) +// ParameterGroup and SubnetGroup are lightweight redshift-specific resources +// (not part of the shared relationaldb driver). The emulator stores their +// identity so IaC that creates and references them succeeds. +type ParameterGroup struct { + Name string + Family string + Description string +} + +type SubnetGroup struct { + Name string + Description string + SubnetIDs []string +} + // Mock is the in-memory AWS Redshift implementation. type Mock struct { mu sync.RWMutex clusters *memstore.Store[rdbdriver.Cluster] clusterSnapshots *memstore.Store[rdbdriver.ClusterSnapshot] + parameterGroups *memstore.Store[ParameterGroup] + subnetGroups *memstore.Store[SubnetGroup] + tagsByARN map[string]map[string]string // ResourceName (ARN) -> tags opts *config.Options monitoring mondriver.Monitoring @@ -55,10 +73,44 @@ func New(opts *config.Options) *Mock { return &Mock{ clusters: memstore.New[rdbdriver.Cluster](), clusterSnapshots: memstore.New[rdbdriver.ClusterSnapshot](), + parameterGroups: memstore.New[ParameterGroup](), + subnetGroups: memstore.New[SubnetGroup](), opts: opts, } } +// CreateClusterParameterGroup registers a redshift cluster parameter group. +func (m *Mock) CreateClusterParameterGroup(_ context.Context, name, family, description string) (*ParameterGroup, error) { + if name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "parameter group name is required") + } + + if m.parameterGroups.Has(name) { + return nil, cerrors.Newf(cerrors.AlreadyExists, "parameter group %q already exists", name) + } + + pg := ParameterGroup{Name: name, Family: family, Description: description} + m.parameterGroups.Set(name, pg) + + return &pg, nil +} + +// CreateClusterSubnetGroup registers a redshift cluster subnet group. +func (m *Mock) CreateClusterSubnetGroup(_ context.Context, name, description string, subnetIDs []string) (*SubnetGroup, error) { + if name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "subnet group name is required") + } + + if m.subnetGroups.Has(name) { + return nil, cerrors.Newf(cerrors.AlreadyExists, "subnet group %q already exists", name) + } + + sg := SubnetGroup{Name: name, Description: description, SubnetIDs: subnetIDs} + m.subnetGroups.Set(name, sg) + + return &sg, nil +} + // SetMonitoring wires a CloudWatch-style backend for auto-metric emission. func (m *Mock) SetMonitoring(mon mondriver.Monitoring) { m.monitoring = mon diff --git a/providers/aws/redshift/tags.go b/providers/aws/redshift/tags.go new file mode 100644 index 00000000..4753af01 --- /dev/null +++ b/providers/aws/redshift/tags.go @@ -0,0 +1,50 @@ +package redshift + +import "context" + +// CreateTags tags a Redshift resource by ARN (ResourceName). Redshift resources +// don't carry a tag field in the shared cluster model, so tags live in an +// ARN-keyed store on the provider. +func (m *Mock) CreateTags(_ context.Context, resourceName string, tags map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.tagsByARN == nil { + m.tagsByARN = map[string]map[string]string{} + } + + if m.tagsByARN[resourceName] == nil { + m.tagsByARN[resourceName] = map[string]string{} + } + + for k, v := range tags { + m.tagsByARN[resourceName][k] = v + } + + return nil +} + +// DeleteTags removes tags by key from a Redshift resource by ARN. +func (m *Mock) DeleteTags(_ context.Context, resourceName string, keys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + for _, k := range keys { + delete(m.tagsByARN[resourceName], k) + } + + return nil +} + +// DescribeTags returns the tags on a Redshift resource by ARN. +func (m *Mock) DescribeTags(_ context.Context, resourceName string) (map[string]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + out := make(map[string]string, len(m.tagsByARN[resourceName])) + for k, v := range m.tagsByARN[resourceName] { + out[k] = v + } + + return out, nil +} diff --git a/providers/aws/route53/route53.go b/providers/aws/route53/route53.go index 861e5780..84b4c703 100644 --- a/providers/aws/route53/route53.go +++ b/providers/aws/route53/route53.go @@ -5,6 +5,7 @@ import ( "context" "maps" "strings" + "sync" "github.com/stackshy/cloudemu/v2/config" "github.com/stackshy/cloudemu/v2/errors" @@ -23,6 +24,9 @@ type Mock struct { records *memstore.Store[driver.RecordInfo] healthChecks *memstore.Store[driver.HealthCheckInfo] opts *config.Options + + tagsMu sync.Mutex + tagsByID map[string]map[string]string // ResourceId -> tags } // New creates a new Route 53 mock with the given configuration options. @@ -32,7 +36,42 @@ func New(opts *config.Options) *Mock { records: memstore.New[driver.RecordInfo](), healthChecks: memstore.New[driver.HealthCheckInfo](), opts: opts, + tagsByID: map[string]map[string]string{}, + } +} + +// ChangeResourceTags applies tag additions and key removals to a Route 53 +// resource (hosted zone or health check) identified by ID. +func (m *Mock) ChangeResourceTags(_ context.Context, resourceID string, add map[string]string, remove []string) error { + m.tagsMu.Lock() + defer m.tagsMu.Unlock() + + if m.tagsByID[resourceID] == nil { + m.tagsByID[resourceID] = map[string]string{} + } + + for k, v := range add { + m.tagsByID[resourceID][k] = v + } + + for _, k := range remove { + delete(m.tagsByID[resourceID], k) + } + + return nil +} + +// ListResourceTags returns the tags on a Route 53 resource by ID. +func (m *Mock) ListResourceTags(_ context.Context, resourceID string) (map[string]string, error) { + m.tagsMu.Lock() + defer m.tagsMu.Unlock() + + out := make(map[string]string, len(m.tagsByID[resourceID])) + for k, v := range m.tagsByID[resourceID] { + out[k] = v } + + return out, nil } // recordKey builds the key used to store a record in the memstore. diff --git a/providers/aws/s3/notification.go b/providers/aws/s3/notification.go new file mode 100644 index 00000000..ebcc4995 --- /dev/null +++ b/providers/aws/s3/notification.go @@ -0,0 +1,105 @@ +package s3 + +import ( + "context" + "encoding/json" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// PutBucketNotification replaces a bucket's SQS notification configuration. +func (m *Mock) PutBucketNotification(_ context.Context, bucket string, configs []QueueNotification) error { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + bkt.notifications = configs + + return nil +} + +// GetBucketNotification returns a bucket's SQS notification configuration. +func (m *Mock) GetBucketNotification(_ context.Context, bucket string) ([]QueueNotification, error) { + bkt, ok := m.buckets.Get(bucket) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "bucket %q not found", bucket) + } + + return bkt.notifications, nil +} + +// notifyObjectCreated delivers an s3:ObjectCreated:Put event (PutObject, copy, +// or completed multipart upload) to matching SQS targets. +func (m *Mock) notifyObjectCreated(bkt *bucketMeta, bucket, key string, size int64) { + m.notify(bkt, bucket, key, size, "ObjectCreated:Put") +} + +// notifyObjectRemoved delivers an s3:ObjectRemoved:Delete event to matching SQS +// targets. +func (m *Mock) notifyObjectRemoved(bkt *bucketMeta, bucket, key string) { + m.notify(bkt, bucket, key, 0, "ObjectRemoved:Delete") +} + +// notify delivers an S3 event to every SQS target configured on the bucket +// whose event filter matches. Best-effort: delivery errors are swallowed so a +// missing/failed queue never fails the object operation (mirroring S3's +// asynchronous, decoupled notification behavior). +func (m *Mock) notify(bkt *bucketMeta, bucket, key string, size int64, eventName string) { + if m.sqs == nil || len(bkt.notifications) == 0 { + return + } + + body := m.objectEventJSON(bucket, key, size, eventName) + + for i := range bkt.notifications { + n := &bkt.notifications[i] + if !eventMatches(n.Events, eventName) { + continue + } + + _ = m.sqs.DeliverExternal(context.Background(), n.QueueARN, body) + } +} + +// eventMatches reports whether an event name satisfies one of the configured +// event selectors. "s3:ObjectCreated:*" matches any ObjectCreated:* event; +// "s3:ObjectCreated:Put" matches exactly. +func eventMatches(selectors []string, eventName string) bool { + full := "s3:" + eventName + + for _, sel := range selectors { + switch { + case sel == full: + return true + case strings.HasSuffix(sel, ":*"): + prefix := strings.TrimSuffix(sel, "*") // "s3:ObjectCreated:" + if strings.HasPrefix(full, prefix) { + return true + } + } + } + + return false +} + +func (m *Mock) objectEventJSON(bucket, key string, size int64, eventName string) string { + record := map[string]any{ + "eventSource": "aws:s3", + "eventName": eventName, + "awsRegion": m.opts.Region, + "eventTime": m.opts.Clock.Now().UTC().Format(s3TimeFormat), + "s3": map[string]any{ + "bucket": map[string]any{"name": bucket}, + "object": map[string]any{"key": key, "size": size}, + }, + } + + b, err := json.Marshal(map[string]any{"Records": []any{record}}) + if err != nil { + return "{}" + } + + return string(b) +} diff --git a/providers/aws/s3/s3.go b/providers/aws/s3/s3.go index 8d4622bd..e083833c 100644 --- a/providers/aws/s3/s3.go +++ b/providers/aws/s3/s3.go @@ -86,6 +86,21 @@ type bucketMeta struct { corsConfig *driver.CORSConfig encryption *driver.EncryptionConfig tags map[string]string + notifications []QueueNotification +} + +// QueueNotification is one S3 bucket-notification target: an SQS queue that +// receives events whose names match one of Events (e.g. "s3:ObjectCreated:*"). +type QueueNotification struct { + ID string + QueueARN string + Events []string +} + +// SQSDeliverer delivers an S3 event notification into an SQS queue by ARN. The +// SQS mock satisfies this, enabling real S3 -> SQS event delivery. +type SQSDeliverer interface { + DeliverExternal(ctx context.Context, queueARN, body string) error } // Mock is an in-memory mock implementation of the AWS S3 service. @@ -93,6 +108,13 @@ type Mock struct { buckets *memstore.Store[*bucketMeta] opts *config.Options monitoring mondriver.Monitoring + sqs SQSDeliverer +} + +// SetSQSDeliverer wires the SQS backend so object-create events deliver to +// buckets' SQS notification targets. +func (m *Mock) SetSQSDeliverer(d SQSDeliverer) { + m.sqs = d } // SetMonitoring sets the monitoring backend for auto-metric generation. @@ -198,6 +220,8 @@ func (m *Mock) PutObject(_ context.Context, bucket, key string, data []byte, con m.emitMetric("PutRequests", 1, "Count", dims) m.emitMetric("BytesUploaded", float64(len(data)), "Bytes", dims) + m.notifyObjectCreated(bkt, bucket, key, int64(len(data))) + return nil } @@ -314,6 +338,8 @@ func (m *Mock) DeleteObject(_ context.Context, bucket, key string) error { m.emitMetric("AllRequests", 1, "Count", dims) m.emitMetric("DeleteRequests", 1, "Count", dims) + m.notifyObjectRemoved(bkt, bucket, key) + return nil } @@ -471,6 +497,8 @@ func (m *Mock) CopyObject(_ context.Context, dstBucket, dstKey string, src drive m.emitMetric("AllRequests", 1, "Count", dims) m.emitMetric("CopyRequests", 1, "Count", dims) + m.notifyObjectCreated(dstBkt, dstBucket, dstKey, int64(len(dataCopy))) + return nil } @@ -714,6 +742,8 @@ func (m *Mock) CompleteMultipartUpload(_ context.Context, bucket, key, uploadID m.emitMetric("PutRequests", 1, "Count", dims) m.emitMetric("BytesUploaded", float64(len(data)), "Bytes", dims) + m.notifyObjectCreated(bkt, bucket, key, int64(len(data))) + return nil } diff --git a/providers/aws/s3/s3_test.go b/providers/aws/s3/s3_test.go index 2023da56..b1de1547 100644 --- a/providers/aws/s3/s3_test.go +++ b/providers/aws/s3/s3_test.go @@ -19,6 +19,78 @@ func newTestMock() *Mock { return New(opts) } +// recordingDeliverer captures S3 -> SQS deliveries for assertion. +type recordingDeliverer struct { + arns []string + bodies []string +} + +func (d *recordingDeliverer) DeliverExternal(_ context.Context, queueARN, body string) error { + d.arns = append(d.arns, queueARN) + d.bodies = append(d.bodies, body) + + return nil +} + +// TestBucketNotificationDelivery is a regression guard for issue #319: a bucket +// with an SQS notification config must deliver an S3 event on object create, +// and only to targets whose event filter matches. +func TestBucketNotificationDelivery(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + rec := &recordingDeliverer{} + m.SetSQSDeliverer(rec) + + if err := m.CreateBucket(ctx, "nb"); err != nil { + t.Fatalf("CreateBucket: %v", err) + } + + if err := m.PutBucketNotification(ctx, "nb", []QueueNotification{ + {QueueARN: "arn:aws:sqs:us-east-1:000000000000:s3events", Events: []string{"s3:ObjectCreated:*"}}, + {QueueARN: "arn:aws:sqs:us-east-1:000000000000:deletes", Events: []string{"s3:ObjectRemoved:*"}}, + }); err != nil { + t.Fatalf("PutBucketNotification: %v", err) + } + + if err := m.PutObject(ctx, "nb", "file1.txt", []byte("hi"), "text/plain", nil); err != nil { + t.Fatalf("PutObject: %v", err) + } + + // Only the ObjectCreated:* target should have received the event. + if len(rec.arns) != 1 || !strings.HasSuffix(rec.arns[0], ":s3events") { + t.Fatalf("deliveries = %v, want one to :s3events", rec.arns) + } + + if !strings.Contains(rec.bodies[0], `"eventName":"ObjectCreated:Put"`) || + !strings.Contains(rec.bodies[0], `"key":"file1.txt"`) { + t.Fatalf("event body = %s", rec.bodies[0]) + } + + // CopyObject also fires ObjectCreated (regression: multipart/copy used to + // notify nothing). + if err := m.CopyObject(ctx, "nb", "file2.txt", driver.CopySource{Bucket: "nb", Key: "file1.txt"}); err != nil { + t.Fatalf("CopyObject: %v", err) + } + + if len(rec.arns) != 2 || !strings.HasSuffix(rec.arns[1], ":s3events") || + !strings.Contains(rec.bodies[1], `"key":"file2.txt"`) { + t.Fatalf("copy delivery = arns %v, body %q", rec.arns, rec.bodies[len(rec.bodies)-1]) + } + + // DeleteObject fires ObjectRemoved:* to the deletes queue (regression: the + // ObjectRemoved selector previously could never receive anything). + if err := m.DeleteObject(ctx, "nb", "file1.txt"); err != nil { + t.Fatalf("DeleteObject: %v", err) + } + + if len(rec.arns) != 3 || !strings.HasSuffix(rec.arns[2], ":deletes") || + !strings.Contains(rec.bodies[2], `"eventName":"ObjectRemoved:Delete"`) || + !strings.Contains(rec.bodies[2], `"key":"file1.txt"`) { + t.Fatalf("delete delivery = arns %v, body %q", rec.arns, rec.bodies[len(rec.bodies)-1]) + } +} + func TestCreateBucket(t *testing.T) { tests := []struct { name string diff --git a/providers/aws/secretsmanager/update.go b/providers/aws/secretsmanager/update.go new file mode 100644 index 00000000..7b9855cb --- /dev/null +++ b/providers/aws/secretsmanager/update.go @@ -0,0 +1,94 @@ +package secretsmanager + +import ( + "context" + "time" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/secrets/driver" +) + +// UpdateSecret updates a secret's description and, when value is non-nil, +// stores it as a new current version (SecretsManager UpdateSecret semantics: +// SecretString/SecretBinary are optional). An empty description leaves the +// existing one unchanged. +func (m *Mock) UpdateSecret(_ context.Context, name, description string, value []byte) (*driver.SecretInfo, error) { + sd, ok := m.secrets.Get(name) + if !ok { + return nil, errors.Newf(errors.NotFound, "secret %q not found", name) + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + if !sd.deletedAt.IsZero() { + return nil, errors.Newf(errors.NotFound, "secret %q is scheduled for deletion", name) + } + + now := m.opts.Clock.Now().UTC().Format(time.RFC3339) + + if description != "" { + sd.info.Description = description + } + + if value != nil { + for i := range sd.versions { + sd.versions[i].Current = false + } + + data := make([]byte, len(value)) + copy(data, value) + + sd.versions = append(sd.versions, driver.SecretVersion{ + VersionID: idgen.GenerateID("ver-"), + Value: data, + CreatedAt: now, + Current: true, + }) + } + + sd.info.UpdatedAt = now + + result := sd.info + + return &result, nil +} + +// TagSecret adds or overwrites tags on a secret (SecretsManager TagResource). +func (m *Mock) TagSecret(_ context.Context, name string, tags map[string]string) error { + sd, ok := m.secrets.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "secret %q not found", name) + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + if sd.info.Tags == nil { + sd.info.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + sd.info.Tags[k] = v + } + + return nil +} + +// UntagSecret removes tags by key from a secret (SecretsManager UntagResource). +func (m *Mock) UntagSecret(_ context.Context, name string, keys []string) error { + sd, ok := m.secrets.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "secret %q not found", name) + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + for _, k := range keys { + delete(sd.info.Tags, k) + } + + return nil +} diff --git a/providers/aws/sns/delivery_test.go b/providers/aws/sns/delivery_test.go new file mode 100644 index 00000000..69cb2736 --- /dev/null +++ b/providers/aws/sns/delivery_test.go @@ -0,0 +1,77 @@ +package sns_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + snsprovider "github.com/stackshy/cloudemu/v2/providers/aws/sns" + sqsprovider "github.com/stackshy/cloudemu/v2/providers/aws/sqs" + mqdriver "github.com/stackshy/cloudemu/v2/services/messagequeue/driver" + sndriver "github.com/stackshy/cloudemu/v2/services/notification/driver" +) + +// TestSNSToSQSDelivery is a regression guard for issue #319: publishing to an +// SNS topic with an SQS subscription must deliver the message to the queue +// (wrapped in the SNS notification envelope). +func TestSNSToSQSDelivery(t *testing.T) { + ctx := context.Background() + opts := config.NewOptions() + + sqs := sqsprovider.New(opts) + sns := snsprovider.New(opts) + sns.SetSQSDeliverer(sqs) + + q, err := sqs.CreateQueue(ctx, mqdriver.QueueConfig{Name: "inbox"}) + if err != nil { + t.Fatalf("CreateQueue: %v", err) + } + + topic, err := sns.CreateTopic(ctx, sndriver.TopicConfig{Name: "feed"}) + if err != nil { + t.Fatalf("CreateTopic: %v", err) + } + + if _, err := sns.Subscribe(ctx, sndriver.SubscriptionConfig{ + TopicID: topic.Name, Protocol: "sqs", Endpoint: q.ARN, + }); err != nil { + t.Fatalf("Subscribe: %v", err) + } + + if _, err := sns.Publish(ctx, sndriver.PublishInput{ + TopicID: topic.Name, Message: "hello", Subject: "hi", + Attributes: map[string]string{"env": "prod"}, + }); err != nil { + t.Fatalf("Publish: %v", err) + } + + msgs, err := sqs.ReceiveMessages(ctx, mqdriver.ReceiveMessageInput{QueueURL: q.URL, MaxMessages: 10}) + if err != nil { + t.Fatalf("ReceiveMessages: %v", err) + } + + if len(msgs) != 1 { + t.Fatalf("expected 1 delivered message, got %d", len(msgs)) + } + + var envelope map[string]any + if err := json.Unmarshal([]byte(msgs[0].Body), &envelope); err != nil { + t.Fatalf("delivered body is not the SNS envelope JSON: %v (%s)", err, msgs[0].Body) + } + + if envelope["Type"] != "Notification" || envelope["Message"] != "hello" || envelope["TopicArn"] != topic.ResourceID { + t.Fatalf("unexpected envelope: %+v", envelope) + } + + // MessageAttributes must survive the SNS -> SQS hop (#320 review). + attrs, ok := envelope["MessageAttributes"].(map[string]any) + if !ok { + t.Fatalf("envelope missing MessageAttributes: %+v", envelope) + } + + env, ok := attrs["env"].(map[string]any) + if !ok || env["Type"] != "String" || env["Value"] != "prod" { + t.Fatalf("unexpected MessageAttributes: %+v", attrs) + } +} diff --git a/providers/aws/sns/sns.go b/providers/aws/sns/sns.go index 95348a65..16c11b69 100644 --- a/providers/aws/sns/sns.go +++ b/providers/aws/sns/sns.go @@ -3,8 +3,10 @@ package sns import ( "context" + "encoding/json" "maps" "sync" + "time" "github.com/stackshy/cloudemu/v2/config" "github.com/stackshy/cloudemu/v2/errors" @@ -33,11 +35,18 @@ type topicData struct { mu sync.RWMutex } +// SQSDeliverer delivers an SNS notification into an SQS queue identified by +// its ARN. The SQS mock satisfies this, enabling real SNS -> SQS fan-out. +type SQSDeliverer interface { + DeliverExternal(ctx context.Context, queueARN, body string) error +} + // Mock is an in-memory mock implementation of the AWS SNS service. type Mock struct { topics *memstore.Store[*topicData] opts *config.Options monitoring mondriver.Monitoring + sqs SQSDeliverer } // SetMonitoring sets the monitoring backend for auto-metric generation. @@ -45,6 +54,11 @@ func (m *Mock) SetMonitoring(mon mondriver.Monitoring) { m.monitoring = mon } +// SetSQSDeliverer wires the SQS backend so publishes fan out to SQS subscriptions. +func (m *Mock) SetSQSDeliverer(d SQSDeliverer) { + m.sqs = d +} + func (m *Mock) emitMetric(metricName string, value float64, unit string, dims map[string]string) { if m.monitoring == nil { return @@ -235,7 +249,7 @@ func (m *Mock) ListSubscriptions(_ context.Context, topicID string) ([]driver.Su } // Publish publishes a message to an SNS topic. -func (m *Mock) Publish(_ context.Context, input driver.PublishInput) (*driver.PublishOutput, error) { +func (m *Mock) Publish(ctx context.Context, input driver.PublishInput) (*driver.PublishOutput, error) { td, ok := m.topics.Get(input.TopicID) if !ok { return nil, errors.Newf(errors.NotFound, "topic %q not found", input.TopicID) @@ -262,9 +276,52 @@ func (m *Mock) Publish(_ context.Context, input driver.PublishInput) (*driver.Pu }) td.mu.Unlock() + m.fanOutToSQS(ctx, td, msgID, input) + dims := map[string]string{"TopicName": input.TopicID} m.emitMetric("NumberOfMessagesPublished", 1, "Count", dims) m.emitMetric("PublishSize", float64(len(input.Message)), "Bytes", dims) return &driver.PublishOutput{MessageID: msgID}, nil } + +// fanOutToSQS delivers a published message to every SQS-protocol subscription +// on the topic, wrapping it in the SNS notification envelope real SNS uses. +func (m *Mock) fanOutToSQS(ctx context.Context, td *topicData, msgID string, input driver.PublishInput) { + if m.sqs == nil { + return + } + + for _, sub := range td.subscriptions.All() { + if sub.Protocol != "sqs" || sub.Endpoint == "" { + continue + } + + env := map[string]any{ + "Type": "Notification", + "MessageId": msgID, + "TopicArn": td.info.ResourceID, + "Subject": input.Subject, + "Message": input.Message, + "Timestamp": m.opts.Clock.Now().UTC().Format(time.RFC3339), + } + + // Real SNS carries publish MessageAttributes into the SQS envelope as + // {name: {"Type": "String", "Value": v}}; preserve them end-to-end. + if len(input.Attributes) > 0 { + attrs := make(map[string]any, len(input.Attributes)) + for k, v := range input.Attributes { + attrs[k] = map[string]string{"Type": "String", "Value": v} + } + + env["MessageAttributes"] = attrs + } + + envelope, err := json.Marshal(env) + if err != nil { + continue + } + + _ = m.sqs.DeliverExternal(ctx, sub.Endpoint, string(envelope)) + } +} diff --git a/providers/aws/sns/sns_test.go b/providers/aws/sns/sns_test.go index b19f4cba..318ba8d9 100644 --- a/providers/aws/sns/sns_test.go +++ b/providers/aws/sns/sns_test.go @@ -107,6 +107,53 @@ func TestCreateTopicWithTags(t *testing.T) { assert.Equal(t, "staging", info.Tags["env"]) } +// TestUpdateTopicDisplayName guards the DisplayName path SetTopicAttributes +// uses (issue #319): UpdateTopic must change the display name in place. +func TestUpdateTopicDisplayName(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateTopic(ctx, driver.TopicConfig{Name: "t"}); err != nil { + t.Fatalf("CreateTopic: %v", err) + } + + if _, err := m.UpdateTopic(ctx, driver.TopicConfig{Name: "t", DisplayName: "My Topic"}); err != nil { + t.Fatalf("UpdateTopic: %v", err) + } + + info, err := m.GetTopic(ctx, "t") + require.NoError(t, err) + assert.Equal(t, "My Topic", info.DisplayName) +} + +// TestTagUntagTopic is a regression guard for issue #319: SNS TagResource / +// UntagResource were unimplemented. +func TestTagUntagTopic(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateTopic(ctx, driver.TopicConfig{Name: "t"}); err != nil { + t.Fatalf("CreateTopic: %v", err) + } + + require.NoError(t, m.TagTopic(ctx, "t", map[string]string{"env": "prod", "team": "infra"})) + + got, err := m.ListTopicTags(ctx, "t") + require.NoError(t, err) + assert.Equal(t, "prod", got["env"]) + assert.Equal(t, "infra", got["team"]) + + require.NoError(t, m.UntagTopic(ctx, "t", []string{"env"})) + + got, err = m.ListTopicTags(ctx, "t") + require.NoError(t, err) + _, has := got["env"] + assert.False(t, has) + assert.Equal(t, "infra", got["team"]) + + assert.Error(t, m.TagTopic(ctx, "missing", map[string]string{"a": "b"})) +} + func TestDeleteTopic(t *testing.T) { tests := []struct { name string diff --git a/providers/aws/sns/tags.go b/providers/aws/sns/tags.go new file mode 100644 index 00000000..42d23da8 --- /dev/null +++ b/providers/aws/sns/tags.go @@ -0,0 +1,63 @@ +package sns + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// TagTopic adds or overwrites tags on a topic (SNS TagResource). +func (m *Mock) TagTopic(_ context.Context, topicName string, tags map[string]string) error { + td, ok := m.topics.Get(topicName) + if !ok { + return errors.Newf(errors.NotFound, "topic %q not found", topicName) + } + + td.mu.Lock() + defer td.mu.Unlock() + + if td.info.Tags == nil { + td.info.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + td.info.Tags[k] = v + } + + return nil +} + +// UntagTopic removes tags by key from a topic (SNS UntagResource). +func (m *Mock) UntagTopic(_ context.Context, topicName string, keys []string) error { + td, ok := m.topics.Get(topicName) + if !ok { + return errors.Newf(errors.NotFound, "topic %q not found", topicName) + } + + td.mu.Lock() + defer td.mu.Unlock() + + for _, k := range keys { + delete(td.info.Tags, k) + } + + return nil +} + +// ListTopicTags returns a topic's tags (SNS ListTagsForResource). +func (m *Mock) ListTopicTags(_ context.Context, topicName string) (map[string]string, error) { + td, ok := m.topics.Get(topicName) + if !ok { + return nil, errors.Newf(errors.NotFound, "topic %q not found", topicName) + } + + td.mu.RLock() + defer td.mu.RUnlock() + + out := make(map[string]string, len(td.info.Tags)) + for k, v := range td.info.Tags { + out[k] = v + } + + return out, nil +} diff --git a/providers/aws/sqs/sqs.go b/providers/aws/sqs/sqs.go index 4fb7b920..4797383e 100644 --- a/providers/aws/sqs/sqs.go +++ b/providers/aws/sqs/sqs.go @@ -108,6 +108,29 @@ func (m *Mock) RemoveTrigger(queueURL string) { delete(m.triggers, queueURL) } +// DeliverExternal enqueues body into the queue identified by ARN. It is used +// for cross-service delivery such as SNS -> SQS and EventBridge -> SQS, where +// the source only knows the target queue's ARN. Returns NotFound if no queue +// matches the ARN. +func (m *Mock) DeliverExternal(ctx context.Context, queueARN, body string) error { + var url string + + for _, qd := range m.queues.SortedValues() { + if qd.info.ARN == queueARN { + url = qd.info.URL + break + } + } + + if url == "" { + return errors.Newf(errors.NotFound, "no queue found for arn %q", queueARN) + } + + _, err := m.SendMessage(ctx, driver.SendMessageInput{QueueURL: url, Body: body}) + + return err +} + // CreateQueue creates a new SQS queue. func (m *Mock) CreateQueue(_ context.Context, cfg driver.QueueConfig) (*driver.QueueInfo, error) { if cfg.Name == "" { diff --git a/providers/aws/sqs/tags.go b/providers/aws/sqs/tags.go new file mode 100644 index 00000000..2d3c0f54 --- /dev/null +++ b/providers/aws/sqs/tags.go @@ -0,0 +1,63 @@ +package sqs + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// TagQueue adds or overwrites tags on a queue (SQS TagQueue). +func (m *Mock) TagQueue(_ context.Context, queueURL string, tags map[string]string) error { + qd, ok := m.queues.Get(queueURL) + if !ok { + return errors.Newf(errors.NotFound, "queue %q not found", queueURL) + } + + qd.mu.Lock() + defer qd.mu.Unlock() + + if qd.info.Tags == nil { + qd.info.Tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + qd.info.Tags[k] = v + } + + return nil +} + +// UntagQueue removes tags by key from a queue (SQS UntagQueue). +func (m *Mock) UntagQueue(_ context.Context, queueURL string, keys []string) error { + qd, ok := m.queues.Get(queueURL) + if !ok { + return errors.Newf(errors.NotFound, "queue %q not found", queueURL) + } + + qd.mu.Lock() + defer qd.mu.Unlock() + + for _, k := range keys { + delete(qd.info.Tags, k) + } + + return nil +} + +// ListQueueTags returns a queue's tags (SQS ListQueueTags). +func (m *Mock) ListQueueTags(_ context.Context, queueURL string) (map[string]string, error) { + qd, ok := m.queues.Get(queueURL) + if !ok { + return nil, errors.Newf(errors.NotFound, "queue %q not found", queueURL) + } + + qd.mu.Lock() + defer qd.mu.Unlock() + + out := make(map[string]string, len(qd.info.Tags)) + for k, v := range qd.info.Tags { + out[k] = v + } + + return out, nil +} diff --git a/providers/aws/ssm/ssm.go b/providers/aws/ssm/ssm.go index 4e7cd152..42810882 100644 --- a/providers/aws/ssm/ssm.go +++ b/providers/aws/ssm/ssm.go @@ -45,6 +45,7 @@ type paramData struct { tier string versions []*version latest int64 + tags map[string]string mu sync.RWMutex } diff --git a/providers/aws/ssm/tags.go b/providers/aws/ssm/tags.go new file mode 100644 index 00000000..b1056527 --- /dev/null +++ b/providers/aws/ssm/tags.go @@ -0,0 +1,65 @@ +package ssm + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" +) + +// TagParameter adds or overwrites tags on a parameter (SSM AddTagsToResource +// with ResourceType=Parameter). +func (m *Mock) TagParameter(_ context.Context, name string, tags map[string]string) error { + pd, ok := m.params.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "parameter %q not found", name) + } + + pd.mu.Lock() + defer pd.mu.Unlock() + + if pd.tags == nil { + pd.tags = make(map[string]string, len(tags)) + } + + for k, v := range tags { + pd.tags[k] = v + } + + return nil +} + +// UntagParameter removes tags by key from a parameter (SSM +// RemoveTagsFromResource). +func (m *Mock) UntagParameter(_ context.Context, name string, keys []string) error { + pd, ok := m.params.Get(name) + if !ok { + return errors.Newf(errors.NotFound, "parameter %q not found", name) + } + + pd.mu.Lock() + defer pd.mu.Unlock() + + for _, k := range keys { + delete(pd.tags, k) + } + + return nil +} + +// ListParameterTags returns a parameter's tags (SSM ListTagsForResource). +func (m *Mock) ListParameterTags(_ context.Context, name string) (map[string]string, error) { + pd, ok := m.params.Get(name) + if !ok { + return nil, errors.Newf(errors.NotFound, "parameter %q not found", name) + } + + pd.mu.RLock() + defer pd.mu.RUnlock() + + out := make(map[string]string, len(pd.tags)) + for k, v := range pd.tags { + out[k] = v + } + + return out, nil +} diff --git a/providers/aws/vpc/clientvpn.go b/providers/aws/vpc/clientvpn.go new file mode 100644 index 00000000..5955a12b --- /dev/null +++ b/providers/aws/vpc/clientvpn.go @@ -0,0 +1,230 @@ +package vpc + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +// CreateClientVPNEndpoint creates a Client VPN endpoint. +// +//nolint:gocritic // cfg matches the driver signature. +func (m *Mock) CreateClientVPNEndpoint(_ context.Context, cfg driver.ClientVPNEndpointConfig) (*driver.ClientVPNEndpoint, error) { + if cfg.ClientCIDRBlock == "" { + return nil, errors.New(errors.InvalidArgument, "clientCidrBlock is required") + } + + if cfg.ServerCertificateARN == "" { + return nil, errors.New(errors.InvalidArgument, "serverCertificateArn is required") + } + + if len(cfg.AuthenticationTypes) == 0 { + return nil, errors.New(errors.InvalidArgument, "at least one authentication option is required") + } + + ep := &driver.ClientVPNEndpoint{ + ID: idgen.GenerateID("cvpn-endpoint-"), + Description: cfg.Description, + ClientCIDRBlock: cfg.ClientCIDRBlock, + ServerCertificateARN: cfg.ServerCertificateARN, + AuthenticationTypes: append([]string(nil), cfg.AuthenticationTypes...), + State: "pending-associate", + SplitTunnel: cfg.SplitTunnel, + Tags: copyTags(cfg.Tags), + } + m.clientVPNEndpoints.Set(ep.ID, ep) + + out := cloneClientVPNEndpoint(ep) + + return &out, nil +} + +// DeleteClientVPNEndpoint deletes a Client VPN endpoint. +func (m *Mock) DeleteClientVPNEndpoint(_ context.Context, id string) error { + if !m.clientVPNEndpoints.Delete(id) { + return errors.Newf(errors.NotFound, "client vpn endpoint %q not found", id) + } + + return nil +} + +// DescribeClientVPNEndpoints returns Client VPN endpoints matching ids. +func (m *Mock) DescribeClientVPNEndpoints(_ context.Context, ids []string) ([]driver.ClientVPNEndpoint, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.clientVPNEndpoints, ids, cloneClientVPNEndpoint), nil +} + +// AssociateClientVPNTargetNetwork associates a subnet with a Client VPN endpoint, +// moving the endpoint to the available state. +func (m *Mock) AssociateClientVPNTargetNetwork( + _ context.Context, endpointID, subnetID string, +) (*driver.ClientVPNTargetNetwork, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ep, ok := m.clientVPNEndpoints.Get(endpointID) + if !ok { + return nil, errors.Newf(errors.NotFound, "client vpn endpoint %q not found", endpointID) + } + + subnet, ok := m.subnets.Get(subnetID) + if !ok { + return nil, errors.Newf(errors.InvalidArgument, "subnet %q not found", subnetID) + } + + assoc := &driver.ClientVPNTargetNetwork{ + AssociationID: idgen.GenerateID("cvpn-assoc-"), + EndpointID: endpointID, + SubnetID: subnetID, + VPCID: subnet.VPCID, + State: "associated", + } + m.clientVPNAssocs.Set(assoc.AssociationID, assoc) + + ep.State = "available" + ep.VPCID = subnet.VPCID + + out := *assoc + + return &out, nil +} + +// DisassociateClientVPNTargetNetwork removes a target-network association. +func (m *Mock) DisassociateClientVPNTargetNetwork(_ context.Context, endpointID, associationID string) error { + assoc, ok := m.clientVPNAssocs.Get(associationID) + if !ok || assoc.EndpointID != endpointID { + return errors.Newf(errors.NotFound, "association %q not found on endpoint %q", associationID, endpointID) + } + + m.clientVPNAssocs.Delete(associationID) + + return nil +} + +func cloneClientVPNEndpoint(e *driver.ClientVPNEndpoint) driver.ClientVPNEndpoint { + out := *e + out.AuthenticationTypes = append([]string(nil), e.AuthenticationTypes...) + out.Tags = copyTags(e.Tags) + + return out +} + +// DescribeClientVPNTargetNetworks returns the target-network associations. +func (m *Mock) DescribeClientVPNTargetNetworks(_ context.Context, endpointID string) ([]driver.ClientVPNTargetNetwork, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.clientVPNEndpoints.Has(endpointID) { + return nil, errors.Newf(errors.NotFound, "client vpn endpoint %q not found", endpointID) + } + + var out []driver.ClientVPNTargetNetwork + + for _, a := range m.clientVPNAssocs.SortedValues() { + if a.EndpointID == endpointID { + out = append(out, *a) + } + } + + return out, nil +} + +// AuthorizeClientVPNIngress authorizes a client CIDR to reach a target network. +func (m *Mock) AuthorizeClientVPNIngress( + _ context.Context, endpointID, targetCIDR, groupID string, accessAll bool, +) (*driver.ClientVPNAuthorizationRule, error) { + if !m.clientVPNEndpoints.Has(endpointID) { + return nil, errors.Newf(errors.NotFound, "client vpn endpoint %q not found", endpointID) + } + + rule := &driver.ClientVPNAuthorizationRule{ + EndpointID: endpointID, TargetCIDR: targetCIDR, GroupID: groupID, + AccessAll: accessAll, Status: "active", + } + m.clientVPNAuthRules.Set(endpointID+"|"+targetCIDR, rule) + + out := *rule + + return &out, nil +} + +// RevokeClientVPNIngress revokes an authorization rule. +func (m *Mock) RevokeClientVPNIngress(_ context.Context, endpointID, targetCIDR string) error { + if !m.clientVPNAuthRules.Delete(endpointID + "|" + targetCIDR) { + return errors.Newf(errors.NotFound, "authorization rule for %q not found", targetCIDR) + } + + return nil +} + +// DescribeClientVPNAuthorizationRules returns the endpoint's authorization rules. +func (m *Mock) DescribeClientVPNAuthorizationRules(_ context.Context, endpointID string) ([]driver.ClientVPNAuthorizationRule, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.clientVPNEndpoints.Has(endpointID) { + return nil, errors.Newf(errors.NotFound, "client vpn endpoint %q not found", endpointID) + } + + var out []driver.ClientVPNAuthorizationRule + + for _, rule := range m.clientVPNAuthRules.SortedValues() { + if rule.EndpointID == endpointID { + out = append(out, *rule) + } + } + + return out, nil +} + +// CreateClientVPNRoute adds a route to a Client VPN endpoint. +func (m *Mock) CreateClientVPNRoute( + _ context.Context, endpointID, destinationCIDR, targetSubnetID string, +) (*driver.ClientVPNRoute, error) { + if !m.clientVPNEndpoints.Has(endpointID) { + return nil, errors.Newf(errors.NotFound, "client vpn endpoint %q not found", endpointID) + } + + route := &driver.ClientVPNRoute{ + EndpointID: endpointID, DestinationCIDR: destinationCIDR, + TargetSubnetID: targetSubnetID, Status: "active", + } + m.clientVPNRoutes.Set(endpointID+"|"+destinationCIDR+"|"+targetSubnetID, route) + + out := *route + + return &out, nil +} + +// DeleteClientVPNRoute removes a route from a Client VPN endpoint. +func (m *Mock) DeleteClientVPNRoute(_ context.Context, endpointID, destinationCIDR, targetSubnetID string) error { + if !m.clientVPNRoutes.Delete(endpointID + "|" + destinationCIDR + "|" + targetSubnetID) { + return errors.Newf(errors.NotFound, "client vpn route %q not found", destinationCIDR) + } + + return nil +} + +// DescribeClientVPNRoutes returns the endpoint's routes. +func (m *Mock) DescribeClientVPNRoutes(_ context.Context, endpointID string) ([]driver.ClientVPNRoute, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.clientVPNEndpoints.Has(endpointID) { + return nil, errors.Newf(errors.NotFound, "client vpn endpoint %q not found", endpointID) + } + + var out []driver.ClientVPNRoute + + for _, route := range m.clientVPNRoutes.SortedValues() { + if route.EndpointID == endpointID { + out = append(out, *route) + } + } + + return out, nil +} diff --git a/providers/aws/vpc/dhcpoptions.go b/providers/aws/vpc/dhcpoptions.go new file mode 100644 index 00000000..e5b60731 --- /dev/null +++ b/providers/aws/vpc/dhcpoptions.go @@ -0,0 +1,75 @@ +package vpc + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +// CreateDHCPOptions creates a DHCP option set. +func (m *Mock) CreateDHCPOptions(_ context.Context, cfg driver.DHCPOptionsConfig) (*driver.DHCPOptions, error) { + opt := &driver.DHCPOptions{ + ID: idgen.GenerateID("dopt-"), + Configuration: cloneDHCPConfig(cfg.Configuration), + Tags: copyTags(cfg.Tags), + } + m.dhcpOptions.Set(opt.ID, opt) + + out := cloneDHCPOptions(opt) + + return &out, nil +} + +// DeleteDHCPOptions deletes a DHCP option set. +func (m *Mock) DeleteDHCPOptions(_ context.Context, id string) error { + if !m.dhcpOptions.Delete(id) { + return errors.Newf(errors.NotFound, "dhcp options %q not found", id) + } + + return nil +} + +// DescribeDHCPOptions returns DHCP option sets matching ids. +func (m *Mock) DescribeDHCPOptions(_ context.Context, ids []string) ([]driver.DHCPOptions, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.dhcpOptions, ids, cloneDHCPOptions), nil +} + +// AssociateDHCPOptions associates a DHCP option set with a VPC. The special id +// "default" resets the VPC to the default (Amazon-provided) options. +func (m *Mock) AssociateDHCPOptions(_ context.Context, dhcpOptionsID, vpcID string) error { + if !m.vpcs.Has(vpcID) { + return errors.Newf(errors.InvalidArgument, "vpc %q not found", vpcID) + } + + if dhcpOptionsID != "default" && !m.dhcpOptions.Has(dhcpOptionsID) { + return errors.Newf(errors.NotFound, "dhcp options %q not found", dhcpOptionsID) + } + + return nil +} + +func cloneDHCPConfig(in map[string][]string) map[string][]string { + if len(in) == 0 { + return nil + } + + out := make(map[string][]string, len(in)) + for k, v := range in { + out[k] = append([]string(nil), v...) + } + + return out +} + +func cloneDHCPOptions(d *driver.DHCPOptions) driver.DHCPOptions { + out := *d + out.Configuration = cloneDHCPConfig(d.Configuration) + out.Tags = copyTags(d.Tags) + + return out +} diff --git a/providers/aws/vpc/egressonlyigw.go b/providers/aws/vpc/egressonlyigw.go new file mode 100644 index 00000000..2a44db7b --- /dev/null +++ b/providers/aws/vpc/egressonlyigw.go @@ -0,0 +1,55 @@ +package vpc + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +// CreateEgressOnlyInternetGateway creates an egress-only internet gateway +// (outbound-only IPv6) attached to a VPC. +func (m *Mock) CreateEgressOnlyInternetGateway( + _ context.Context, vpcID string, tags map[string]string, +) (*driver.EgressOnlyInternetGateway, error) { + if !m.vpcs.Has(vpcID) { + return nil, errors.Newf(errors.InvalidArgument, "vpc %q not found", vpcID) + } + + eigw := &driver.EgressOnlyInternetGateway{ + ID: idgen.GenerateID("eigw-"), + AttachedVPCID: vpcID, + State: "attached", + Tags: copyTags(tags), + } + m.egressOnlyIGWs.Set(eigw.ID, eigw) + + out := cloneEgressOnlyIGW(eigw) + + return &out, nil +} + +// DeleteEgressOnlyInternetGateway deletes an egress-only internet gateway. +func (m *Mock) DeleteEgressOnlyInternetGateway(_ context.Context, id string) error { + if !m.egressOnlyIGWs.Delete(id) { + return errors.Newf(errors.NotFound, "egress-only internet gateway %q not found", id) + } + + return nil +} + +// DescribeEgressOnlyInternetGateways returns egress-only IGWs matching ids. +func (m *Mock) DescribeEgressOnlyInternetGateways(_ context.Context, ids []string) ([]driver.EgressOnlyInternetGateway, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.egressOnlyIGWs, ids, cloneEgressOnlyIGW), nil +} + +func cloneEgressOnlyIGW(e *driver.EgressOnlyInternetGateway) driver.EgressOnlyInternetGateway { + out := *e + out.Tags = copyTags(e.Tags) + + return out +} diff --git a/providers/aws/vpc/endpointservice.go b/providers/aws/vpc/endpointservice.go new file mode 100644 index 00000000..80a02302 --- /dev/null +++ b/providers/aws/vpc/endpointservice.go @@ -0,0 +1,104 @@ +package vpc + +import ( + "context" + "fmt" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +// CreateVPCEndpointServiceConfiguration publishes a PrivateLink endpoint service +// backed by one or more network load balancers. +func (m *Mock) CreateVPCEndpointServiceConfiguration( + _ context.Context, cfg driver.EndpointServiceConfig, +) (*driver.EndpointService, error) { + if len(cfg.NetworkLoadBalancerARNs) == 0 { + return nil, errors.New(errors.InvalidArgument, "at least one network load balancer ARN is required") + } + + id := idgen.GenerateID("vpce-svc-") + svc := &driver.EndpointService{ + ID: id, + ServiceName: fmt.Sprintf("com.amazonaws.vpce.%s.%s", m.opts.Region, id), + State: "Available", + NetworkLoadBalancerARNs: append([]string(nil), cfg.NetworkLoadBalancerARNs...), + AcceptanceRequired: cfg.AcceptanceRequired, + AvailabilityZones: []string{m.opts.Region + "a"}, + Tags: copyTags(cfg.Tags), + } + m.endpointServices.Set(id, svc) + + out := cloneEndpointService(svc) + + return &out, nil +} + +// DeleteVPCEndpointServiceConfiguration deletes an endpoint service. +func (m *Mock) DeleteVPCEndpointServiceConfiguration(_ context.Context, id string) error { + if !m.endpointServices.Delete(id) { + return errors.Newf(errors.NotFound, "endpoint service %q not found", id) + } + + return nil +} + +// DescribeVPCEndpointServiceConfigurations returns endpoint services matching ids. +func (m *Mock) DescribeVPCEndpointServiceConfigurations(_ context.Context, ids []string) ([]driver.EndpointService, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.endpointServices, ids, cloneEndpointService), nil +} + +func cloneEndpointService(s *driver.EndpointService) driver.EndpointService { + out := *s + out.NetworkLoadBalancerARNs = append([]string(nil), s.NetworkLoadBalancerARNs...) + out.AvailabilityZones = append([]string(nil), s.AvailabilityZones...) + out.Tags = copyTags(s.Tags) + + return out +} + +// ModifyVPCEndpointServicePermissions adds/removes allowed principals on a service. +func (m *Mock) ModifyVPCEndpointServicePermissions( + _ context.Context, serviceID string, addPrincipals, removePrincipals []string, +) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.endpointServices.Has(serviceID) { + return errors.Newf(errors.NotFound, "endpoint service %q not found", serviceID) + } + + remove := make(map[string]bool, len(removePrincipals)) + for _, p := range removePrincipals { + remove[p] = true + } + + current := m.endpointServicePerms[serviceID] + kept := current[:0:0] + + for _, p := range current { + if !remove[p] { + kept = append(kept, p) + } + } + + m.endpointServicePerms[serviceID] = append(kept, addPrincipals...) + + return nil +} + +// DescribeVPCEndpointServicePermissions returns the allowed principals. +func (m *Mock) DescribeVPCEndpointServicePermissions(_ context.Context, serviceID string) ([]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.endpointServices.Has(serviceID) { + return nil, errors.Newf(errors.NotFound, "endpoint service %q not found", serviceID) + } + + return append([]string(nil), m.endpointServicePerms[serviceID]...), nil +} diff --git a/providers/aws/vpc/eni.go b/providers/aws/vpc/eni.go index 312ee7a1..cc16dbaf 100644 --- a/providers/aws/vpc/eni.go +++ b/providers/aws/vpc/eni.go @@ -24,6 +24,36 @@ type eniData struct { Tags map[string]string } +// CreateNetworkInterface creates a standalone, unattached ENI in the given +// subnet (ec2:CreateNetworkInterface). The VPC is resolved from the subnet, so +// an unknown subnet is NotFound. +func (m *Mock) CreateNetworkInterface( + _ context.Context, subnetID, description string, tags map[string]string, +) (*driver.NetworkInterface, error) { + m.mu.Lock() + defer m.mu.Unlock() + + sub, ok := m.subnets.Get(subnetID) + if !ok { + return nil, errors.Newf(errors.NotFound, "InvalidSubnetID.NotFound: subnet %q not found", subnetID) + } + + id := idgen.GenerateID("eni-") + eni := &eniData{ + ID: id, + VPCID: sub.VPCID, + SubnetID: subnetID, + Status: ENIStatusAvailable, + Description: description, + Tags: copyTags(tags), + } + m.enis.Set(id, eni) + + info := toENIInfo(eni) + + return &info, nil +} + // DescribeNetworkInterfaces returns ENIs matching the given IDs, or all if empty. // // An explicitly named ID that does not exist is NotFound rather than an empty diff --git a/providers/aws/vpc/ipam.go b/providers/aws/vpc/ipam.go new file mode 100644 index 00000000..006bc40b --- /dev/null +++ b/providers/aws/vpc/ipam.go @@ -0,0 +1,593 @@ +package vpc + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +const ( + ipamStateDeleteComplete = "delete-complete" + ipamStateDeprovisioned = "deprovisioned" +) + +// ipamARN builds an IPAM-family ARN. IPAM ARNs carry no region segment +// (arn:aws:ec2:::), matching the real service. +func (m *Mock) ipamARN(resource string) string { + return idgen.AWSARN("ec2", "", m.opts.AccountID, resource) +} + +// CreateIpam creates an IPAM plus its public and private default scopes. +func (m *Mock) CreateIpam(_ context.Context, cfg driver.IpamConfig) (*driver.Ipam, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idgen.GenerateID("ipam-") + ipamARN := m.ipamARN("ipam/" + id) + + pub := m.newDefaultScope(ipamARN, "public") + priv := m.newDefaultScope(ipamARN, "private") + + ipam := &driver.Ipam{ + ID: id, + ARN: ipamARN, + Region: m.opts.Region, + PublicDefaultScopeID: pub.ID, + PrivateDefaultScopeID: priv.ID, + ScopeCount: 2, + OperatingRegions: cfg.OperatingRegions, + Description: cfg.Description, + Tier: orDefaultStr(cfg.Tier, "advanced"), + State: "create-complete", + Tags: copyTags(cfg.Tags), + } + + // A new IPAM gets a default resource discovery associated with it. + rd := m.newResourceDiscovery(true, "", nil) + assoc := m.newRDAssociation(ipam, rd.ID, true, nil) + ipam.DefaultResourceDiscoveryID = rd.ID + ipam.DefaultResourceDiscoveryAssociationID = assoc.ID + ipam.ResourceDiscoveryAssociationCount = 1 + + m.ipams.Set(id, ipam) + + out := cloneIpam(ipam) + + return &out, nil +} + +// newDefaultScope creates and stores a default scope for an IPAM. Caller holds mu. +func (m *Mock) newDefaultScope(ipamARN, scopeType string) *driver.IpamScope { + id := idgen.GenerateID("ipam-scope-") + scope := &driver.IpamScope{ + ID: id, + ARN: m.ipamARN("ipam-scope/" + id), + IpamARN: ipamARN, + ScopeType: scopeType, + IsDefault: true, + State: "create-complete", + } + m.ipamScopes.Set(id, scope) + + return scope +} + +// DescribeIpams returns IPAMs matching ids (all if empty). +func (m *Mock) DescribeIpams(_ context.Context, ids []string) ([]driver.Ipam, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.ipams, ids, cloneIpam), nil +} + +// ModifyIpam updates an IPAM's description. +func (m *Mock) ModifyIpam(_ context.Context, id, description string) (*driver.Ipam, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ipam, ok := m.ipams.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam %q not found", id) + } + + ipam.Description = description + + out := cloneIpam(ipam) + + return &out, nil +} + +// DeleteIpam deletes an IPAM and its default scopes. Non-default scopes or +// pools referencing the IPAM block deletion. +// +//nolint:gocyclo // sequential precondition checks; flattening hurts readability +func (m *Mock) DeleteIpam(_ context.Context, id string) (*driver.Ipam, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ipam, ok := m.ipams.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam %q not found", id) + } + + for _, s := range m.ipamScopes.SortedValues() { + if s.IpamARN == ipam.ARN && !s.IsDefault { + return nil, errors.Newf(errors.FailedPrecondition, "ipam %q has non-default scopes", id) + } + } + + if m.poolsInScopes(ipam.ARN) { + return nil, errors.Newf(errors.FailedPrecondition, "ipam %q has pools", id) + } + + for _, a := range m.ipamRDAssociations.SortedValues() { + if a.IpamID == id && !a.IsDefault { + return nil, errors.Newf(errors.FailedPrecondition, "ipam %q has resource-discovery associations", id) + } + } + + for _, s := range m.ipamScopes.SortedValues() { + if s.IpamARN == ipam.ARN { + m.ipamScopes.Delete(s.ID) + } + } + + // Tear down the default resource discovery + association. + for _, a := range m.ipamRDAssociations.SortedValues() { + if a.IpamID == id { + m.ipamRDAssociations.Delete(a.ID) + } + } + + m.ipamDiscoveries.Delete(ipam.DefaultResourceDiscoveryID) + + ipam.State = ipamStateDeleteComplete + + m.ipams.Delete(id) + + out := cloneIpam(ipam) + + return &out, nil +} + +// CreateIpamScope creates a non-default (private) scope within an IPAM. +func (m *Mock) CreateIpamScope(_ context.Context, cfg driver.IpamScopeConfig) (*driver.IpamScope, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ipam, ok := m.ipams.Get(cfg.IpamID) + if !ok { + return nil, errors.Newf(errors.InvalidArgument, "ipam %q not found", cfg.IpamID) + } + + id := idgen.GenerateID("ipam-scope-") + scope := &driver.IpamScope{ + ID: id, + ARN: m.ipamARN("ipam-scope/" + id), + IpamARN: ipam.ARN, + ScopeType: "private", + IsDefault: false, + Description: cfg.Description, + State: "create-complete", + Tags: copyTags(cfg.Tags), + } + m.ipamScopes.Set(id, scope) + + ipam.ScopeCount++ + + out := cloneIpamScope(scope) + + return &out, nil +} + +// DescribeIpamScopes returns scopes matching ids (all if empty). +func (m *Mock) DescribeIpamScopes(_ context.Context, ids []string) ([]driver.IpamScope, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.ipamScopes, ids, cloneIpamScope), nil +} + +// ModifyIpamScope updates a scope's description. +func (m *Mock) ModifyIpamScope(_ context.Context, id, description string) (*driver.IpamScope, error) { + m.mu.Lock() + defer m.mu.Unlock() + + scope, ok := m.ipamScopes.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam scope %q not found", id) + } + + scope.Description = description + + out := cloneIpamScope(scope) + + return &out, nil +} + +// DeleteIpamScope deletes a non-default scope with no pools. +func (m *Mock) DeleteIpamScope(_ context.Context, id string) (*driver.IpamScope, error) { + m.mu.Lock() + defer m.mu.Unlock() + + scope, ok := m.ipamScopes.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam scope %q not found", id) + } + + if scope.IsDefault { + return nil, errors.Newf(errors.FailedPrecondition, "cannot delete default scope %q", id) + } + + for _, p := range m.ipamPools.SortedValues() { + if p.IpamScopeARN == scope.ARN { + return nil, errors.Newf(errors.FailedPrecondition, "ipam scope %q has pools", id) + } + } + + scope.State = ipamStateDeleteComplete + + m.ipamScopes.Delete(id) + + if ipam := m.ipamByARN(scope.IpamARN); ipam != nil { + ipam.ScopeCount-- + } + + out := cloneIpamScope(scope) + + return &out, nil +} + +// CreateIpamPool creates a CIDR pool in a scope. +// +//nolint:gocritic // cfg matches the driver signature. +func (m *Mock) CreateIpamPool(_ context.Context, cfg driver.IpamPoolConfig) (*driver.IpamPool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + scope, ok := m.ipamScopes.Get(cfg.IpamScopeID) + if !ok { + return nil, errors.Newf(errors.InvalidArgument, "ipam scope %q not found", cfg.IpamScopeID) + } + + id := idgen.GenerateID("ipam-pool-") + pool := &driver.IpamPool{ + ID: id, + ARN: m.ipamARN("ipam-pool/" + id), + IpamScopeARN: scope.ARN, + IpamScopeType: scope.ScopeType, + AddressFamily: orDefaultStr(cfg.AddressFamily, "ipv4"), + Locale: orDefaultStr(cfg.Locale, "None"), + PoolDepth: 1, + Description: cfg.Description, + State: "create-complete", + AllocationMinNetmaskLength: cfg.AllocationMinNetmaskLength, + AllocationMaxNetmaskLength: cfg.AllocationMaxNetmaskLength, + AllocationDefaultNetmaskLength: cfg.AllocationDefaultNetmaskLength, + Tags: copyTags(cfg.Tags), + } + m.ipamPools.Set(id, pool) + + scope.PoolCount++ + + out := cloneIpamPool(pool) + + return &out, nil +} + +// DescribeIpamPools returns pools matching ids (all if empty). +func (m *Mock) DescribeIpamPools(_ context.Context, ids []string) ([]driver.IpamPool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.ipamPools, ids, cloneIpamPool), nil +} + +// ModifyIpamPool updates a pool's description. +func (m *Mock) ModifyIpamPool(_ context.Context, id, description string) (*driver.IpamPool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + pool, ok := m.ipamPools.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam pool %q not found", id) + } + + pool.Description = description + + out := cloneIpamPool(pool) + + return &out, nil +} + +// DeleteIpamPool deletes a pool with no live allocations. +func (m *Mock) DeleteIpamPool(_ context.Context, id string) (*driver.IpamPool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + pool, ok := m.ipamPools.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam pool %q not found", id) + } + + for _, a := range m.ipamAllocations.SortedValues() { + if m.ipamPoolByAllocation[a.ID] == id { + return nil, errors.Newf(errors.FailedPrecondition, "ipam pool %q has allocations", id) + } + } + + for _, c := range m.ipamPoolCidrs.SortedValues() { + if m.ipamPoolByCidr[c.ID] == id { + m.ipamPoolCidrs.Delete(c.ID) + delete(m.ipamPoolByCidr, c.ID) + } + } + + pool.State = ipamStateDeleteComplete + + m.ipamPools.Delete(id) + + if scope := m.scopeByARN(pool.IpamScopeARN); scope != nil { + scope.PoolCount-- + } + + out := cloneIpamPool(pool) + + return &out, nil +} + +// ProvisionIpamPoolCidr adds a CIDR to a pool's supply. +func (m *Mock) ProvisionIpamPoolCidr(_ context.Context, poolID, cidr string, netmaskLength int) (*driver.IpamPoolCidr, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.ipamPools.Has(poolID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam pool %q not found", poolID) + } + + if cidr == "" && netmaskLength == 0 { + return nil, errors.New(errors.InvalidArgument, "a cidr or netmaskLength is required") + } + + // AWS's common pattern is netmask-only ("provision a /24"); derive a + // concrete CIDR so downstream reads and AWS/IPAM metrics aren't corrupted + // by an empty CIDR string. + if cidr == "" { + derived, ok := m.deriveProvisionCIDR(poolID, netmaskLength) + if !ok { + return nil, errors.Newf(errors.InvalidArgument, + "unable to provision a /%d cidr from the pool's available space", netmaskLength) + } + + cidr = derived + } + + id := idgen.GenerateID("ipam-pool-cidr-") + pc := &driver.IpamPoolCidr{ + ID: id, + CIDR: cidr, + NetmaskLength: netmaskLength, + State: "provisioned", + } + m.ipamPoolCidrs.Set(id, pc) + m.ipamPoolByCidr[id] = poolID + + out := *pc + + return &out, nil +} + +// DeprovisionIpamPoolCidr removes a provisioned CIDR from a pool. +func (m *Mock) DeprovisionIpamPoolCidr(_ context.Context, poolID, cidr string) (*driver.IpamPoolCidr, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.ipamPools.Has(poolID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam pool %q not found", poolID) + } + + for _, c := range m.ipamPoolCidrs.SortedValues() { + if m.ipamPoolByCidr[c.ID] != poolID || c.CIDR != cidr { + continue + } + + c.State = ipamStateDeprovisioned + + m.ipamPoolCidrs.Delete(c.ID) + delete(m.ipamPoolByCidr, c.ID) + + out := *c + + return &out, nil + } + + return nil, errors.Newf(errors.NotFound, "cidr %q not provisioned in pool %q", cidr, poolID) +} + +// GetIpamPoolCidrs returns the CIDRs provisioned into a pool. +func (m *Mock) GetIpamPoolCidrs(_ context.Context, poolID string) ([]driver.IpamPoolCidr, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.ipamPools.Has(poolID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam pool %q not found", poolID) + } + + var out []driver.IpamPoolCidr + + for _, c := range m.ipamPoolCidrs.SortedValues() { + if m.ipamPoolByCidr[c.ID] == poolID { + out = append(out, *c) + } + } + + return out, nil +} + +// AllocateIpamPoolCidr hands out a CIDR from a pool. +func (m *Mock) AllocateIpamPoolCidr(_ context.Context, cfg driver.AllocateIpamPoolCidrConfig) (*driver.IpamPoolAllocation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.ipamPools.Has(cfg.IpamPoolID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam pool %q not found", cfg.IpamPoolID) + } + + if cfg.CIDR == "" && cfg.NetmaskLength == 0 { + return nil, errors.New(errors.InvalidArgument, "a cidr or netmaskLength is required") + } + + // Netmask-only allocation ("give me a /24 from this pool") is the standard + // AWS pattern; carve a concrete free block from the pool's provisioned + // supply so the returned allocation and AWS/IPAM metrics are correct. + cidr := cfg.CIDR + if cidr == "" { + derived, ok := m.deriveAllocationCIDR(cfg.IpamPoolID, cfg.NetmaskLength) + if !ok { + return nil, errors.Newf(errors.InvalidArgument, + "unable to allocate a /%d cidr from the pool's available space", cfg.NetmaskLength) + } + + cidr = derived + } + + id := idgen.GenerateID("ipam-pool-alloc-") + alloc := &driver.IpamPoolAllocation{ + ID: id, + CIDR: cidr, + ResourceType: "custom", + Description: cfg.Description, + Tags: copyTags(cfg.Tags), + } + m.ipamAllocations.Set(id, alloc) + m.ipamPoolByAllocation[id] = cfg.IpamPoolID + + out := cloneIpamAllocation(alloc) + + return &out, nil +} + +// ReleaseIpamPoolAllocation frees an allocation. +func (m *Mock) ReleaseIpamPoolAllocation(_ context.Context, poolID, allocationID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.ipamPoolByAllocation[allocationID] != poolID || !m.ipamAllocations.Has(allocationID) { + return errors.Newf(errors.NotFound, "allocation %q not found in pool %q", allocationID, poolID) + } + + m.ipamAllocations.Delete(allocationID) + delete(m.ipamPoolByAllocation, allocationID) + + return nil +} + +// GetIpamPoolAllocations returns the allocations handed out from a pool. +func (m *Mock) GetIpamPoolAllocations(_ context.Context, poolID string) ([]driver.IpamPoolAllocation, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.ipamPools.Has(poolID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam pool %q not found", poolID) + } + + var out []driver.IpamPoolAllocation + + for _, a := range m.ipamAllocations.SortedValues() { + if m.ipamPoolByAllocation[a.ID] == poolID { + out = append(out, cloneIpamAllocation(a)) + } + } + + return out, nil +} + +// ModifyIpamPoolAllocation updates an allocation's description. +func (m *Mock) ModifyIpamPoolAllocation(_ context.Context, allocationID, description string) (*driver.IpamPoolAllocation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + alloc, ok := m.ipamAllocations.Get(allocationID) + if !ok { + return nil, errors.Newf(errors.NotFound, "allocation %q not found", allocationID) + } + + alloc.Description = description + + out := cloneIpamAllocation(alloc) + + return &out, nil +} + +// ---- internal lookups (caller holds mu) ---- + +func (m *Mock) ipamByARN(arn string) *driver.Ipam { + for _, i := range m.ipams.SortedValues() { + if i.ARN == arn { + return i + } + } + + return nil +} + +func (m *Mock) scopeByARN(arn string) *driver.IpamScope { + for _, s := range m.ipamScopes.SortedValues() { + if s.ARN == arn { + return s + } + } + + return nil +} + +func (m *Mock) poolsInScopes(ipamARN string) bool { + scopeARNs := make(map[string]bool) + + for _, s := range m.ipamScopes.SortedValues() { + if s.IpamARN == ipamARN { + scopeARNs[s.ARN] = true + } + } + + for _, p := range m.ipamPools.SortedValues() { + if scopeARNs[p.IpamScopeARN] { + return true + } + } + + return false +} + +// ---- clones ---- + +func cloneIpam(i *driver.Ipam) driver.Ipam { + out := *i + out.OperatingRegions = append([]string(nil), i.OperatingRegions...) + out.Tags = copyTags(i.Tags) + + return out +} + +func cloneIpamScope(s *driver.IpamScope) driver.IpamScope { + out := *s + out.Tags = copyTags(s.Tags) + + return out +} + +func cloneIpamPool(p *driver.IpamPool) driver.IpamPool { + out := *p + out.Tags = copyTags(p.Tags) + + return out +} + +func cloneIpamAllocation(a *driver.IpamPoolAllocation) driver.IpamPoolAllocation { + out := *a + out.Tags = copyTags(a.Tags) + + return out +} diff --git a/providers/aws/vpc/ipam_byoip.go b/providers/aws/vpc/ipam_byoip.go new file mode 100644 index 00000000..3213b763 --- /dev/null +++ b/providers/aws/vpc/ipam_byoip.go @@ -0,0 +1,217 @@ +package vpc + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +// ProvisionIpamByoasn provisions a bring-your-own ASN into an IPAM. +func (m *Mock) ProvisionIpamByoasn(_ context.Context, ipamID, asn string) (*driver.Byoasn, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.ipams.Has(ipamID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam %q not found", ipamID) + } + + if asn == "" { + return nil, errors.New(errors.InvalidArgument, "asn is required") + } + + b := &driver.Byoasn{Asn: asn, IpamID: ipamID, State: "provisioned"} + m.ipamByoasns.Set(asn, b) + + out := *b + + return &out, nil +} + +// DeprovisionIpamByoasn removes a BYOASN. +func (m *Mock) DeprovisionIpamByoasn(_ context.Context, _, asn string) (*driver.Byoasn, error) { + m.mu.Lock() + defer m.mu.Unlock() + + b, ok := m.ipamByoasns.Get(asn) + if !ok { + return nil, errors.Newf(errors.NotFound, "byoasn %q not found", asn) + } + + b.State = ipamStateDeprovisioned + + m.ipamByoasns.Delete(asn) + + out := *b + + return &out, nil +} + +// DescribeIpamByoasn returns all provisioned BYOASNs. +func (m *Mock) DescribeIpamByoasn(_ context.Context) ([]driver.Byoasn, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + all := m.ipamByoasns.SortedValues() + out := make([]driver.Byoasn, 0, len(all)) + + for _, b := range all { + out = append(out, *b) + } + + return out, nil +} + +// AssociateIpamByoasn associates a BYOASN with a BYOIP CIDR. +func (m *Mock) AssociateIpamByoasn(_ context.Context, asn, cidr string) (*driver.AsnAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.ipamByoasns.Has(asn) { + return nil, errors.Newf(errors.InvalidArgument, "byoasn %q not found", asn) + } + + bc, ok := m.ipamByoipCidrs.Get(cidr) + if !ok { + return nil, errors.Newf(errors.InvalidArgument, "byoip cidr %q not provisioned", cidr) + } + + assoc := driver.AsnAssociation{Asn: asn, CIDR: cidr, State: "associated"} + bc.AsnAssociations = append(bc.AsnAssociations, assoc) + + return &assoc, nil +} + +// DisassociateIpamByoasn removes a BYOASN↔CIDR association. +func (m *Mock) DisassociateIpamByoasn(_ context.Context, asn, cidr string) (*driver.AsnAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.ipamByoasns.Has(asn) { + return nil, errors.Newf(errors.InvalidArgument, "byoasn %q not found", asn) + } + + assoc := driver.AsnAssociation{Asn: asn, CIDR: cidr, State: "disassociated"} + + if bc, ok := m.ipamByoipCidrs.Get(cidr); ok { + kept := bc.AsnAssociations[:0:0] + + for _, a := range bc.AsnAssociations { + if a.Asn != asn { + kept = append(kept, a) + } + } + + bc.AsnAssociations = kept + } + + return &assoc, nil +} + +// ProvisionByoipCidr provisions a bring-your-own public IP CIDR. +func (m *Mock) ProvisionByoipCidr(_ context.Context, cidr, description string) (*driver.ByoipCidr, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if cidr == "" { + return nil, errors.New(errors.InvalidArgument, "cidr is required") + } + + bc := &driver.ByoipCidr{CIDR: cidr, Description: description, State: "provisioned"} + m.ipamByoipCidrs.Set(cidr, bc) + + out := cloneByoipCidr(bc) + + return &out, nil +} + +// DeprovisionByoipCidr removes a BYOIP CIDR. +func (m *Mock) DeprovisionByoipCidr(_ context.Context, cidr string) (*driver.ByoipCidr, error) { + m.mu.Lock() + defer m.mu.Unlock() + + bc, ok := m.ipamByoipCidrs.Get(cidr) + if !ok { + return nil, errors.Newf(errors.NotFound, "byoip cidr %q not found", cidr) + } + + if bc.AdvertisementType == "advertised" { + return nil, errors.Newf(errors.FailedPrecondition, "byoip cidr %q is advertised", cidr) + } + + bc.State = ipamStateDeprovisioned + + m.ipamByoipCidrs.Delete(cidr) + + out := cloneByoipCidr(bc) + + return &out, nil +} + +// MoveByoipCidrToIpam moves a BYOIP CIDR under IPAM management. +func (m *Mock) MoveByoipCidrToIpam(_ context.Context, cidr, ipamPoolID string) (*driver.ByoipCidr, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.ipamPools.Has(ipamPoolID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam pool %q not found", ipamPoolID) + } + + bc, ok := m.ipamByoipCidrs.Get(cidr) + if !ok { + bc = &driver.ByoipCidr{CIDR: cidr, State: "provisioned"} + m.ipamByoipCidrs.Set(cidr, bc) + } + + out := cloneByoipCidr(bc) + + return &out, nil +} + +// DescribeByoipCidrs returns all BYOIP CIDRs. +func (m *Mock) DescribeByoipCidrs(_ context.Context) ([]driver.ByoipCidr, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + all := m.ipamByoipCidrs.SortedValues() + out := make([]driver.ByoipCidr, 0, len(all)) + + for _, bc := range all { + out = append(out, cloneByoipCidr(bc)) + } + + return out, nil +} + +// AdvertiseByoipCidr advertises a BYOIP CIDR to the internet. +func (m *Mock) AdvertiseByoipCidr(_ context.Context, cidr string) (*driver.ByoipCidr, error) { + return m.setByoipAdvertisement(cidr, "advertised") +} + +// WithdrawByoipCidr withdraws a BYOIP CIDR advertisement. +func (m *Mock) WithdrawByoipCidr(_ context.Context, cidr string) (*driver.ByoipCidr, error) { + return m.setByoipAdvertisement(cidr, "withdrawn") +} + +func (m *Mock) setByoipAdvertisement(cidr, advertisement string) (*driver.ByoipCidr, error) { + m.mu.Lock() + defer m.mu.Unlock() + + bc, ok := m.ipamByoipCidrs.Get(cidr) + if !ok { + return nil, errors.Newf(errors.NotFound, "byoip cidr %q not found", cidr) + } + + bc.AdvertisementType = advertisement + + out := cloneByoipCidr(bc) + + return &out, nil +} + +func cloneByoipCidr(bc *driver.ByoipCidr) driver.ByoipCidr { + out := *bc + out.AsnAssociations = append([]driver.AsnAssociation(nil), bc.AsnAssociations...) + + return out +} diff --git a/providers/aws/vpc/ipam_cidr.go b/providers/aws/vpc/ipam_cidr.go new file mode 100644 index 00000000..ffa5c762 --- /dev/null +++ b/providers/aws/vpc/ipam_cidr.go @@ -0,0 +1,166 @@ +package vpc + +import ( + "encoding/binary" + "fmt" + "net" +) + +// defaultIPv4Base is the space a top-level pool provisions from when a caller +// asks for a netmask-only CIDR (real AWS carves that from a parent pool; this +// single-account emulator has no pool hierarchy, so it hands out from a large +// private base). /8 is ample for any realistic requested netmask. +const defaultIPv4Base = "10.0.0.0/8" + +// ipv4Bits is the width of an IPv4 address, used for netmask/host arithmetic. +const ipv4Bits = 32 + +// ipv4Range returns the inclusive [start,end] address range of an IPv4 CIDR. +func ipv4Range(cidr string) (start, end uint32, ok bool) { + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil || ipnet.IP.To4() == nil { + return 0, 0, false + } + + base := binary.BigEndian.Uint32(ipnet.IP.To4()) + ones, bits := ipnet.Mask.Size() + size := uint32(1) << uint(bits-ones) //nolint:gosec // bits-ones is 0..32, no overflow + + return base, base + size - 1, true +} + +// blockFree reports whether the /blockSize block starting at b overlaps none of +// the used ranges. +func blockFree(b, blockSize uint32, used [][2]uint32) bool { + end := b + blockSize - 1 + + for _, u := range used { + if b <= u[1] && u[0] <= end { + return false + } + } + + return true +} + +// nextFreeIPv4Block finds the first netmask-aligned /netmask block inside +// [parentStart,parentEnd] that doesn't overlap any used range, and returns it +// as a CIDR string. Sequential carve: it does not reclaim gaps between used +// ranges beyond simple alignment scanning, which is enough for realistic +// allocate-then-allocate usage. +func nextFreeIPv4Block(parentStart, parentEnd uint32, used [][2]uint32, netmask int) (string, bool) { + if netmask < 0 || netmask > ipv4Bits { + return "", false + } + + blockSize := uint32(1) << uint(ipv4Bits-netmask) //nolint:gosec // 32-netmask is 0..32, no overflow + + // Align the first candidate up to a blockSize boundary. + start := parentStart + if rem := start % blockSize; rem != 0 { + start += blockSize - rem + } + + for b := start; b >= parentStart && b+blockSize-1 <= parentEnd; b += blockSize { + if blockFree(b, blockSize, used) { + ip := make(net.IP, net.IPv4len) + binary.BigEndian.PutUint32(ip, b) + + return fmt.Sprintf("%s/%d", ip.String(), netmask), true + } + + if b+blockSize < b { // guard against uint32 wraparound at the top of the space + break + } + } + + return "", false +} + +// usedRanges converts a list of CIDR strings to inclusive IPv4 ranges, skipping +// any that aren't parseable IPv4. +func usedRanges(cidrs []string) [][2]uint32 { + out := make([][2]uint32, 0, len(cidrs)) + + for _, c := range cidrs { + if s, e, ok := ipv4Range(c); ok { + out = append(out, [2]uint32{s, e}) + } + } + + return out +} + +// deriveAllocationCIDR carves a free /netmask block from a pool's provisioned +// supply, skipping blocks already handed out as allocations. Caller holds mu. +func (m *Mock) deriveAllocationCIDR(poolID string, netmask int) (string, bool) { + used := usedRanges(m.poolAllocationCIDRs(poolID)) + + for _, sup := range m.poolProvisionedCIDRs(poolID) { + s, e, ok := ipv4Range(sup) + if !ok { + continue + } + + if cidr, found := nextFreeIPv4Block(s, e, used, netmask); found { + return cidr, true + } + } + + return "", false +} + +// deriveProvisionCIDR synthesizes a /netmask block for a top-level pool from +// the default private base, skipping CIDRs already provisioned into the pool. +// Caller holds mu. +func (m *Mock) deriveProvisionCIDR(_ string, netmask int) (string, bool) { + s, e, _ := ipv4Range(defaultIPv4Base) + // Every top-level pool carves from the same shared base, so a new block must + // avoid CIDRs already provisioned into ANY pool — otherwise two pools each + // asking for a /16 would both receive 10.0.0.0/16 and overlap, defeating + // IPAM's non-overlap guarantee. + used := usedRanges(m.allProvisionedCIDRs()) + + return nextFreeIPv4Block(s, e, used, netmask) +} + +// poolProvisionedCIDRs / poolAllocationCIDRs list the non-empty CIDR strings a +// pool has provisioned / allocated. Caller holds mu. +func (m *Mock) poolProvisionedCIDRs(poolID string) []string { + var out []string + + for _, c := range m.ipamPoolCidrs.SortedValues() { + if m.ipamPoolByCidr[c.ID] == poolID && c.CIDR != "" { + out = append(out, c.CIDR) + } + } + + return out +} + +// allProvisionedCIDRs lists every non-empty provisioned CIDR across all pools. +// Used to keep provisioning non-overlapping when pools share a carve base. +// Caller holds mu. +func (m *Mock) allProvisionedCIDRs() []string { + var out []string + + for _, c := range m.ipamPoolCidrs.SortedValues() { + if c.CIDR != "" { + out = append(out, c.CIDR) + } + } + + return out +} + +func (m *Mock) poolAllocationCIDRs(poolID string) []string { + var out []string + + for _, a := range m.ipamAllocations.SortedValues() { + if m.ipamPoolByAllocation[a.ID] == poolID && a.CIDR != "" { + out = append(out, a.CIDR) + } + } + + return out +} diff --git a/providers/aws/vpc/ipam_discovery.go b/providers/aws/vpc/ipam_discovery.go new file mode 100644 index 00000000..c5d04e74 --- /dev/null +++ b/providers/aws/vpc/ipam_discovery.go @@ -0,0 +1,292 @@ +package vpc + +import ( + "context" + "time" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +// newResourceDiscovery creates and stores a resource discovery. Caller holds mu. +func (m *Mock) newResourceDiscovery( + isDefault bool, description string, tags map[string]string, +) *driver.IpamResourceDiscovery { + id := idgen.GenerateID("ipam-res-disco-") + rd := &driver.IpamResourceDiscovery{ + ID: id, + ARN: m.ipamARN("ipam-resource-discovery/" + id), + Region: m.opts.Region, + OwnerID: m.opts.AccountID, + OperatingRegions: []string{m.opts.Region}, + Description: description, + State: "create-complete", + IsDefault: isDefault, + Tags: copyTags(tags), + } + m.ipamDiscoveries.Set(id, rd) + + return rd +} + +// newRDAssociation associates a resource discovery with an IPAM. Caller holds mu. +func (m *Mock) newRDAssociation( + ipam *driver.Ipam, rdID string, isDefault bool, tags map[string]string, +) *driver.IpamResourceDiscoveryAssociation { + id := idgen.GenerateID("ipam-res-disco-assoc-") + assoc := &driver.IpamResourceDiscoveryAssociation{ + ID: id, + ARN: m.ipamARN("ipam-resource-discovery-association/" + id), + IpamID: ipam.ID, + IpamARN: ipam.ARN, + IpamRegion: ipam.Region, + ResourceDiscoveryID: rdID, + OwnerID: m.opts.AccountID, + State: "associate-complete", + IsDefault: isDefault, + ResourceDiscoveryStatus: "active", + Tags: copyTags(tags), + } + m.ipamRDAssociations.Set(id, assoc) + + return assoc +} + +// CreateIpamResourceDiscovery creates a non-default resource discovery. +func (m *Mock) CreateIpamResourceDiscovery( + _ context.Context, cfg driver.IpamResourceDiscoveryConfig, +) (*driver.IpamResourceDiscovery, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rd := m.newResourceDiscovery(false, cfg.Description, cfg.Tags) + + if len(cfg.OperatingRegions) > 0 { + rd.OperatingRegions = append([]string(nil), cfg.OperatingRegions...) + } + + out := cloneResourceDiscovery(rd) + + return &out, nil +} + +// DescribeIpamResourceDiscoveries returns resource discoveries matching ids. +func (m *Mock) DescribeIpamResourceDiscoveries(_ context.Context, ids []string) ([]driver.IpamResourceDiscovery, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.ipamDiscoveries, ids, cloneResourceDiscovery), nil +} + +// ModifyIpamResourceDiscovery updates a resource discovery's description/regions. +func (m *Mock) ModifyIpamResourceDiscovery( + _ context.Context, id, description string, operatingRegions []string, +) (*driver.IpamResourceDiscovery, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.ipamDiscoveries.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam resource discovery %q not found", id) + } + + if rd.IsDefault { + return nil, errors.Newf(errors.FailedPrecondition, "cannot modify default resource discovery %q", id) + } + + rd.Description = description + if len(operatingRegions) > 0 { + rd.OperatingRegions = append([]string(nil), operatingRegions...) + } + + out := cloneResourceDiscovery(rd) + + return &out, nil +} + +// DeleteIpamResourceDiscovery deletes a non-default, unassociated resource discovery. +func (m *Mock) DeleteIpamResourceDiscovery(_ context.Context, id string) (*driver.IpamResourceDiscovery, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rd, ok := m.ipamDiscoveries.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam resource discovery %q not found", id) + } + + if rd.IsDefault { + return nil, errors.Newf(errors.FailedPrecondition, "cannot delete default resource discovery %q", id) + } + + for _, a := range m.ipamRDAssociations.SortedValues() { + if a.ResourceDiscoveryID == id { + return nil, errors.Newf(errors.FailedPrecondition, "resource discovery %q is associated", id) + } + } + + rd.State = ipamStateDeleteComplete + + m.ipamDiscoveries.Delete(id) + + out := cloneResourceDiscovery(rd) + + return &out, nil +} + +// AssociateIpamResourceDiscovery associates a resource discovery with an IPAM. +func (m *Mock) AssociateIpamResourceDiscovery( + _ context.Context, ipamID, resourceDiscoveryID string, tags map[string]string, +) (*driver.IpamResourceDiscoveryAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ipam, ok := m.ipams.Get(ipamID) + if !ok { + return nil, errors.Newf(errors.InvalidArgument, "ipam %q not found", ipamID) + } + + if !m.ipamDiscoveries.Has(resourceDiscoveryID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam resource discovery %q not found", resourceDiscoveryID) + } + + assoc := m.newRDAssociation(ipam, resourceDiscoveryID, false, tags) + + ipam.ResourceDiscoveryAssociationCount++ + + out := cloneRDAssociation(assoc) + + return &out, nil +} + +// DisassociateIpamResourceDiscovery removes a resource-discovery association. +func (m *Mock) DisassociateIpamResourceDiscovery( + _ context.Context, associationID string, +) (*driver.IpamResourceDiscoveryAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + assoc, ok := m.ipamRDAssociations.Get(associationID) + if !ok { + return nil, errors.Newf(errors.NotFound, "resource discovery association %q not found", associationID) + } + + if assoc.IsDefault { + return nil, errors.Newf(errors.FailedPrecondition, "cannot disassociate default association %q", associationID) + } + + assoc.State = "disassociate-complete" + + m.ipamRDAssociations.Delete(associationID) + + if ipam, ok := m.ipams.Get(assoc.IpamID); ok { + ipam.ResourceDiscoveryAssociationCount-- + } + + out := cloneRDAssociation(assoc) + + return &out, nil +} + +// DescribeIpamResourceDiscoveryAssociations returns associations matching ids. +func (m *Mock) DescribeIpamResourceDiscoveryAssociations( + _ context.Context, ids []string, +) ([]driver.IpamResourceDiscoveryAssociation, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.ipamRDAssociations, ids, cloneRDAssociation), nil +} + +// GetIpamDiscoveredAccounts returns the accounts a resource discovery monitors. +// The emulator is single-account, so it reports the configured account. +func (m *Mock) GetIpamDiscoveredAccounts(_ context.Context, resourceDiscoveryID, region string) ([]driver.IpamDiscoveredAccount, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.ipamDiscoveries.Has(resourceDiscoveryID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam resource discovery %q not found", resourceDiscoveryID) + } + + return []driver.IpamDiscoveredAccount{{ + AccountID: m.opts.AccountID, + DiscoveryRegion: orDefaultStr(region, m.opts.Region), + LastAttemptedDiscoveryTime: time.Unix(0, 0).UTC(), + LastSuccessfulDiscoveryTime: time.Unix(0, 0).UTC(), + }}, nil +} + +// GetIpamDiscoveredResourceCidrs returns the resource CIDRs a discovery found, +// derived from the stored VPCs/subnets. +func (m *Mock) GetIpamDiscoveredResourceCidrs( + _ context.Context, resourceDiscoveryID, region string, +) ([]driver.IpamDiscoveredResourceCidr, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.ipamDiscoveries.Has(resourceDiscoveryID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam resource discovery %q not found", resourceDiscoveryID) + } + + cidrs := m.ipamResourceCidrs() + out := make([]driver.IpamDiscoveredResourceCidr, 0, len(cidrs)) + + for i := range cidrs { + c := cidrs[i] + out = append(out, driver.IpamDiscoveredResourceCidr{ + ResourceDiscoveryID: resourceDiscoveryID, ResourceCIDR: c.ResourceCIDR, ResourceID: c.ResourceID, + ResourceType: c.ResourceType, ResourceRegion: orDefaultStr(region, m.opts.Region), + ResourceOwnerID: c.ResourceOwnerID, VPCID: c.VPCID, IPSource: "amazon", IPUsage: c.IPUsage, + NetworkInterfaceAttachmentStatus: "available", SampleTime: time.Unix(0, 0).UTC(), + }) + } + + return out, nil +} + +// GetIpamDiscoveredPublicAddresses returns the public IPs a discovery found, +// derived from the stored Elastic IPs. +func (m *Mock) GetIpamDiscoveredPublicAddresses( + _ context.Context, resourceDiscoveryID, region string, +) ([]driver.IpamDiscoveredPublicAddress, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.ipamDiscoveries.Has(resourceDiscoveryID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam resource discovery %q not found", resourceDiscoveryID) + } + + eips := m.eips.SortedValues() + out := make([]driver.IpamDiscoveredPublicAddress, 0, len(eips)) + + for _, e := range eips { + status := "disassociated" + if e.AssociationID != "" { + status = "associated" + } + + out = append(out, driver.IpamDiscoveredPublicAddress{ + ResourceDiscoveryID: resourceDiscoveryID, Address: e.PublicIP, AddressAllocationID: e.AllocationID, + AddressOwnerID: m.opts.AccountID, AddressRegion: orDefaultStr(region, m.opts.Region), + AddressType: "amazon-owned-eip", AssociationStatus: status, Service: "ec2", + SampleTime: time.Unix(0, 0).UTC(), + }) + } + + return out, nil +} + +func cloneResourceDiscovery(rd *driver.IpamResourceDiscovery) driver.IpamResourceDiscovery { + out := *rd + out.OperatingRegions = append([]string(nil), rd.OperatingRegions...) + out.Tags = copyTags(rd.Tags) + + return out +} + +func cloneRDAssociation(a *driver.IpamResourceDiscoveryAssociation) driver.IpamResourceDiscoveryAssociation { + out := *a + out.Tags = copyTags(a.Tags) + + return out +} diff --git a/providers/aws/vpc/ipam_metrics.go b/providers/aws/vpc/ipam_metrics.go new file mode 100644 index 00000000..ecedcfb2 --- /dev/null +++ b/providers/aws/vpc/ipam_metrics.go @@ -0,0 +1,201 @@ +package vpc + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +const hundredPercent = 100.0 + +// IpamMetrics derives the AWS/IPAM CloudWatch metrics from current state: +// per-IPAM TotalActiveIpCount, per-scope resource-CIDR counts, per-pool +// allocation percentages, public-IP insight counts, and per-VPC/subnet IP +// utilization. Values are computed live so they track the emulator's state. +func (m *Mock) IpamMetrics(_ context.Context) []driver.IpamMetric { + m.mu.RLock() + defer m.mu.RUnlock() + + var out []driver.IpamMetric + + resourceCidrs := m.ipamResourceCidrs() + + out = append(out, m.ipamTopLevelMetrics(resourceCidrs)...) + out = append(out, m.ipamScopeMetrics()...) + out = append(out, m.ipamPoolMetrics()...) + out = append(out, m.ipamPublicIPMetrics()...) + out = append(out, m.ipamResourceUtilizationMetrics(resourceCidrs)...) + + return out +} + +func metric(name string, value float64, unit string, dims map[string]string) driver.IpamMetric { + return driver.IpamMetric{ + Namespace: driver.IpamMetricNamespace, MetricName: name, Value: value, Unit: unit, Dimensions: dims, + } +} + +// ipamTopLevelMetrics emits TotalActiveIpCount per IPAM (active = addresses on +// resource CIDRs the IPAM tracks). +func (m *Mock) ipamTopLevelMetrics(resourceCidrs []driver.IpamResourceCidr) []driver.IpamMetric { + if m.ipams.Len() == 0 { + return nil + } + + var active float64 + + for i := range resourceCidrs { + c := resourceCidrs[i] + if c.ResourceType == resourceTypeVPC { + active += cidrSize(c.ResourceCIDR) * c.IPUsage + } + } + + out := make([]driver.IpamMetric, 0, m.ipams.Len()) + + for _, i := range m.ipams.SortedValues() { + out = append(out, metric("TotalActiveIpCount", active, "Count", map[string]string{"IpamId": i.ID})) + } + + return out +} + +// ipamScopeMetrics emits per-scope resource-CIDR compliance counts. +func (m *Mock) ipamScopeMetrics() []driver.IpamMetric { + scopes := m.ipamScopes.SortedValues() + out := make([]driver.IpamMetric, 0, len(scopes)) + + managed := float64(m.vpcs.Len() + m.subnets.Len()) + + for _, s := range scopes { + dims := map[string]string{"ScopeID": s.ID} + out = append(out, + metric("ManagedResourceCidrs", managed, "Count", dims), + metric("CompliantResourceCidrs", managed, "Count", dims), + metric("NoncompliantResourceCidrs", 0, "Count", dims), + metric("OverlappingResourceCidrs", 0, "Count", dims), + metric("UnmanagedResourceCidrs", 0, "Count", dims), + ) + } + + return out +} + +// ipamPoolMetrics emits per-pool allocation percentages + compliance counts. +func (m *Mock) ipamPoolMetrics() []driver.IpamMetric { + pools := m.ipamPools.SortedValues() + out := make([]driver.IpamMetric, 0, len(pools)) + + for _, p := range pools { + supply := m.poolSupply(p.ID) + assigned := m.poolAssigned(p.ID) + + var pctAssigned float64 + if supply > 0 { + pctAssigned = assigned / supply * hundredPercent + } + + dims := map[string]string{"PoolID": p.ID, "AddressFamily": p.AddressFamily} + out = append(out, + metric("PercentAssigned", pctAssigned, "Percent", dims), + metric("PercentAllocated", pctAssigned, "Percent", dims), + metric("PercentAvailable", hundredPercent-pctAssigned, "Percent", dims), + metric("CompliantResourceCidrs", float64(m.poolAllocationCount(p.ID)), "Count", dims), + metric("NoncompliantResourceCidrs", 0, "Count", dims), + ) + } + + return out +} + +// ipamPublicIPMetrics emits public-IP insight counts from Elastic IPs + BYOIP. +func (m *Mock) ipamPublicIPMetrics() []driver.IpamMetric { + if m.ipams.Len() == 0 { + return nil + } + + var associated, unassociated float64 + + for _, e := range m.eips.SortedValues() { + if e.AssociationID != "" { + associated++ + } else { + unassociated++ + } + } + + byoip := float64(m.ipamByoipCidrs.Len()) + + ipams := m.ipams.SortedValues() + out := make([]driver.IpamMetric, 0, len(ipams)) + + for _, i := range ipams { + dims := map[string]string{"IpamId": i.ID} + out = append(out, + metric("AmazonOwnedElasticIPs", associated+unassociated, "Count", dims), + metric("AssociatedAmazonOwnedElasticIPs", associated, "Count", dims), + metric("UnassociatedAmazonOwnedElasticIPs", unassociated, "Count", dims), + metric("BringYourOwnIPs", byoip, "Count", dims), + ) + } + + return out +} + +// ipamResourceUtilizationMetrics emits VpcIPUsage / SubnetIPUsage per resource. +func (*Mock) ipamResourceUtilizationMetrics(resourceCidrs []driver.IpamResourceCidr) []driver.IpamMetric { + var out []driver.IpamMetric + + for i := range resourceCidrs { + c := resourceCidrs[i] + switch c.ResourceType { + case resourceTypeVPC: + out = append(out, metric("VpcIPUsage", c.IPUsage*hundredPercent, "Percent", map[string]string{ + "VpcID": c.ResourceID, "AddressFamily": "IPv4", "Region": c.ResourceRegion, + })) + case resourceTypeSubnet: + out = append(out, metric("SubnetIPUsage", c.IPUsage*hundredPercent, "Percent", map[string]string{ + "SubnetID": c.ResourceID, "VpcID": c.VPCID, "AddressFamily": "IPv4", "Region": c.ResourceRegion, + })) + } + } + + return out +} + +// poolSupply / poolAssigned / poolAllocationCount summarize a pool. Caller holds mu. +func (m *Mock) poolSupply(poolID string) float64 { + var total float64 + + for _, c := range m.ipamPoolCidrs.SortedValues() { + if m.ipamPoolByCidr[c.ID] == poolID { + total += cidrSize(c.CIDR) + } + } + + return total +} + +func (m *Mock) poolAssigned(poolID string) float64 { + var assigned float64 + + for _, a := range m.ipamAllocations.SortedValues() { + if m.ipamPoolByAllocation[a.ID] == poolID { + assigned += cidrSize(a.CIDR) + } + } + + return assigned +} + +func (m *Mock) poolAllocationCount(poolID string) int { + var n int + + for _, a := range m.ipamAllocations.SortedValues() { + if m.ipamPoolByAllocation[a.ID] == poolID { + n++ + } + } + + return n +} diff --git a/providers/aws/vpc/ipam_policy.go b/providers/aws/vpc/ipam_policy.go new file mode 100644 index 00000000..b21421eb --- /dev/null +++ b/providers/aws/vpc/ipam_policy.go @@ -0,0 +1,180 @@ +package vpc + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +// CreateIpamPolicy creates an allocation policy for an IPAM. +func (m *Mock) CreateIpamPolicy(_ context.Context, ipamID string, tags map[string]string) (*driver.IpamPolicy, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ipam, ok := m.ipams.Get(ipamID) + if !ok { + return nil, errors.Newf(errors.InvalidArgument, "ipam %q not found", ipamID) + } + + id := idgen.GenerateID("ipam-policy-") + p := &driver.IpamPolicy{ + ID: id, ARN: m.ipamARN("ipam-policy/" + id), IpamID: ipamID, IpamRegion: ipam.Region, + OwnerID: m.opts.AccountID, State: "create-complete", Tags: copyTags(tags), + } + m.ipamPolicies.Set(id, p) + + out := cloneIpamPolicy(p) + + return &out, nil +} + +// DeleteIpamPolicy deletes an IPAM policy. +func (m *Mock) DeleteIpamPolicy(_ context.Context, id string) (*driver.IpamPolicy, error) { + m.mu.Lock() + defer m.mu.Unlock() + + p, ok := m.ipamPolicies.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam policy %q not found", id) + } + + p.State = ipamStateDeleteComplete + + m.ipamPolicies.Delete(id) + + out := cloneIpamPolicy(p) + + return &out, nil +} + +// DescribeIpamPolicies returns policies matching ids. +func (m *Mock) DescribeIpamPolicies(_ context.Context, ids []string) ([]driver.IpamPolicy, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.ipamPolicies, ids, cloneIpamPolicy), nil +} + +// EnableIpamPolicy enables a policy (optionally for an org target). +func (m *Mock) EnableIpamPolicy(_ context.Context, id, _ string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + p, ok := m.ipamPolicies.Get(id) + if !ok { + return "", errors.Newf(errors.NotFound, "ipam policy %q not found", id) + } + + p.Enabled = true + + return p.ID, nil +} + +// DisableIpamPolicy disables a policy. +func (m *Mock) DisableIpamPolicy(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + p, ok := m.ipamPolicies.Get(id) + if !ok { + return errors.Newf(errors.NotFound, "ipam policy %q not found", id) + } + + p.Enabled = false + + return nil +} + +// GetEnabledIpamPolicy returns the currently-enabled policy, if any. +func (m *Mock) GetEnabledIpamPolicy(_ context.Context) (policyID string, enabled bool, managedBy string, err error) { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, p := range m.ipamPolicies.SortedValues() { + if p.Enabled { + return p.ID, true, m.opts.AccountID, nil + } + } + + return "", false, "", nil +} + +// ModifyIpamPolicyAllocationRules replaces a policy's allocation rules and the +// locale/resource-type the document is scoped to, returning the updated policy. +func (m *Mock) ModifyIpamPolicyAllocationRules( + _ context.Context, id, locale, resourceType string, rules []driver.IpamAllocationRule, +) (*driver.IpamPolicy, error) { + m.mu.Lock() + defer m.mu.Unlock() + + p, ok := m.ipamPolicies.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam policy %q not found", id) + } + + p.Locale = locale + p.ResourceType = resourceType + + p.AllocationRules = append([]driver.IpamAllocationRule(nil), rules...) + + out := cloneIpamPolicy(p) + + return &out, nil +} + +// GetIpamPolicyAllocationRules returns the policy carrying its allocation-rule +// document (rules + locale + resource type). +func (m *Mock) GetIpamPolicyAllocationRules(_ context.Context, id string) (*driver.IpamPolicy, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + p, ok := m.ipamPolicies.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam policy %q not found", id) + } + + out := cloneIpamPolicy(p) + + return &out, nil +} + +// GetIpamPolicyOrganizationTargets returns the org targets a policy applies to. +// The emulator is single-account, so it reports the configured account. +func (m *Mock) GetIpamPolicyOrganizationTargets(_ context.Context, id string) ([]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.ipamPolicies.Has(id) { + return nil, errors.Newf(errors.NotFound, "ipam policy %q not found", id) + } + + return []string{m.opts.AccountID}, nil +} + +// EnableIpamOrganizationAdminAccount delegates IPAM admin to an account. +func (*Mock) EnableIpamOrganizationAdminAccount(_ context.Context, accountID string) (bool, error) { + if accountID == "" { + return false, errors.New(errors.InvalidArgument, "delegatedAdminAccountId is required") + } + + return true, nil +} + +// DisableIpamOrganizationAdminAccount removes the delegated IPAM admin. +func (*Mock) DisableIpamOrganizationAdminAccount(_ context.Context, accountID string) (bool, error) { + if accountID == "" { + return false, errors.New(errors.InvalidArgument, "delegatedAdminAccountId is required") + } + + return true, nil +} + +func cloneIpamPolicy(p *driver.IpamPolicy) driver.IpamPolicy { + out := *p + out.AllocationRules = append([]driver.IpamAllocationRule(nil), p.AllocationRules...) + out.Tags = copyTags(p.Tags) + + return out +} diff --git a/providers/aws/vpc/ipam_resolver.go b/providers/aws/vpc/ipam_resolver.go new file mode 100644 index 00000000..4efe9883 --- /dev/null +++ b/providers/aws/vpc/ipam_resolver.go @@ -0,0 +1,290 @@ +package vpc + +import ( + "context" + "time" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +// CreateIpamPrefixListResolver creates a prefix-list resolver in an IPAM. +func (m *Mock) CreateIpamPrefixListResolver( + _ context.Context, ipamID, addressFamily, description string, tags map[string]string, +) (*driver.IpamPrefixListResolver, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ipam, ok := m.ipams.Get(ipamID) + if !ok { + return nil, errors.Newf(errors.InvalidArgument, "ipam %q not found", ipamID) + } + + id := idgen.GenerateID("ipam-pl-res-") + r := &driver.IpamPrefixListResolver{ + ID: id, ARN: m.ipamARN("ipam-prefix-list-resolver/" + id), IpamID: ipamID, IpamARN: ipam.ARN, + IpamRegion: ipam.Region, OwnerID: m.opts.AccountID, AddressFamily: orDefaultStr(addressFamily, "ipv4"), + Description: description, State: "create-complete", LastVersionCreationStatus: "success", Tags: copyTags(tags), + } + m.ipamResolvers.Set(id, r) + + out := cloneResolver(r) + + return &out, nil +} + +// DescribeIpamPrefixListResolvers returns resolvers matching ids. +func (m *Mock) DescribeIpamPrefixListResolvers(_ context.Context, ids []string) ([]driver.IpamPrefixListResolver, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.ipamResolvers, ids, cloneResolver), nil +} + +// ModifyIpamPrefixListResolver updates a resolver's description. +func (m *Mock) ModifyIpamPrefixListResolver(_ context.Context, id, description string) (*driver.IpamPrefixListResolver, error) { + m.mu.Lock() + defer m.mu.Unlock() + + r, ok := m.ipamResolvers.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam prefix list resolver %q not found", id) + } + + r.Description = description + + out := cloneResolver(r) + + return &out, nil +} + +// DeleteIpamPrefixListResolver deletes a resolver with no targets. +func (m *Mock) DeleteIpamPrefixListResolver(_ context.Context, id string) (*driver.IpamPrefixListResolver, error) { + m.mu.Lock() + defer m.mu.Unlock() + + r, ok := m.ipamResolvers.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam prefix list resolver %q not found", id) + } + + for _, t := range m.ipamResolverTargets.SortedValues() { + if t.ResolverID == id { + return nil, errors.Newf(errors.FailedPrecondition, "resolver %q has targets", id) + } + } + + r.State = ipamStateDeleteComplete + + m.ipamResolvers.Delete(id) + + out := cloneResolver(r) + + return &out, nil +} + +// CreateIpamPrefixListResolverTarget adds a managed prefix list as a sync target. +func (m *Mock) CreateIpamPrefixListResolverTarget( + _ context.Context, resolverID, prefixListID, prefixListRegion string, + desiredVersion int, trackLatest bool, tags map[string]string, +) (*driver.IpamPrefixListResolverTarget, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.ipamResolvers.Has(resolverID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam prefix list resolver %q not found", resolverID) + } + + if !m.prefixLists.Has(prefixListID) { + return nil, errors.Newf(errors.InvalidArgument, "managed prefix list %q not found", prefixListID) + } + + id := idgen.GenerateID("ipam-pl-res-target-") + t := &driver.IpamPrefixListResolverTarget{ + ID: id, ARN: m.ipamARN("ipam-prefix-list-resolver-target/" + id), ResolverID: resolverID, + OwnerID: m.opts.AccountID, PrefixListID: prefixListID, PrefixListRegion: orDefaultStr(prefixListRegion, m.opts.Region), + DesiredVersion: desiredVersion, LastSyncedVersion: desiredVersion, TrackLatestVersion: trackLatest, + State: "create-complete", Tags: copyTags(tags), + } + m.ipamResolverTargets.Set(id, t) + + out := cloneResolverTarget(t) + + return &out, nil +} + +// DescribeIpamPrefixListResolverTargets returns targets matching ids. +func (m *Mock) DescribeIpamPrefixListResolverTargets(_ context.Context, ids []string) ([]driver.IpamPrefixListResolverTarget, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.ipamResolverTargets, ids, cloneResolverTarget), nil +} + +// ModifyIpamPrefixListResolverTarget updates a target's version tracking. +func (m *Mock) ModifyIpamPrefixListResolverTarget( + _ context.Context, id string, desiredVersion int, trackLatest bool, +) (*driver.IpamPrefixListResolverTarget, error) { + m.mu.Lock() + defer m.mu.Unlock() + + t, ok := m.ipamResolverTargets.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam prefix list resolver target %q not found", id) + } + + if desiredVersion > 0 { + t.DesiredVersion = desiredVersion + t.LastSyncedVersion = desiredVersion + } + + t.TrackLatestVersion = trackLatest + + out := cloneResolverTarget(t) + + return &out, nil +} + +// DeleteIpamPrefixListResolverTarget removes a sync target. +func (m *Mock) DeleteIpamPrefixListResolverTarget(_ context.Context, id string) (*driver.IpamPrefixListResolverTarget, error) { + m.mu.Lock() + defer m.mu.Unlock() + + t, ok := m.ipamResolverTargets.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam prefix list resolver target %q not found", id) + } + + t.State = ipamStateDeleteComplete + + m.ipamResolverTargets.Delete(id) + + out := cloneResolverTarget(t) + + return &out, nil +} + +// GetIpamPrefixListResolverRules returns a resolver's rules. The emulator does +// not model a rule engine, so it derives one rule per pool in the same IPAM. +func (m *Mock) GetIpamPrefixListResolverRules(_ context.Context, resolverID string) ([]driver.IpamPrefixListResolverRule, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + r, ok := m.ipamResolvers.Get(resolverID) + if !ok { + return nil, errors.Newf(errors.InvalidArgument, "ipam prefix list resolver %q not found", resolverID) + } + + var out []driver.IpamPrefixListResolverRule + + for _, p := range m.ipamPools.SortedValues() { + if scope := m.scopeByARN(p.IpamScopeARN); scope != nil && scope.IpamARN == r.IpamARN { + out = append(out, driver.IpamPrefixListResolverRule{IpamPoolID: p.ID}) + } + } + + return out, nil +} + +// GetIpamPrefixListResolverVersions returns the resolver's published versions. +func (m *Mock) GetIpamPrefixListResolverVersions(_ context.Context, resolverID string) ([]driver.IpamPrefixListResolverVersion, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.ipamResolvers.Has(resolverID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam prefix list resolver %q not found", resolverID) + } + + return []driver.IpamPrefixListResolverVersion{{Version: 1, CreatedAt: time.Unix(0, 0).UTC()}}, nil +} + +// GetIpamPrefixListResolverVersionEntries returns the CIDR entries of a version. +func (m *Mock) GetIpamPrefixListResolverVersionEntries(_ context.Context, resolverID string, _ int) ([]driver.PrefixListEntry, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.ipamResolvers.Has(resolverID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam prefix list resolver %q not found", resolverID) + } + + return nil, nil +} + +// CreateIpamExternalResourceVerificationToken issues a verification token. +func (m *Mock) CreateIpamExternalResourceVerificationToken( + _ context.Context, ipamID, tokenName string, tags map[string]string, +) (*driver.IpamExternalResourceVerificationToken, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ipam, ok := m.ipams.Get(ipamID) + if !ok { + return nil, errors.Newf(errors.InvalidArgument, "ipam %q not found", ipamID) + } + + id := idgen.GenerateID("ipam-ext-verify-token-") + t := &driver.IpamExternalResourceVerificationToken{ + ID: id, ARN: m.ipamARN("ipam-external-resource-verification-token/" + id), IpamID: ipamID, IpamARN: ipam.ARN, + IpamRegion: ipam.Region, OwnerID: m.opts.AccountID, TokenName: tokenName, + TokenValue: idgen.GenerateID("token-"), NotAfter: time.Unix(0, 0).UTC(), + State: "create-complete", Status: "valid", Tags: copyTags(tags), + } + m.ipamTokens.Set(id, t) + + out := cloneToken(t) + + return &out, nil +} + +// DeleteIpamExternalResourceVerificationToken deletes a verification token. +func (m *Mock) DeleteIpamExternalResourceVerificationToken( + _ context.Context, id string, +) (*driver.IpamExternalResourceVerificationToken, error) { + m.mu.Lock() + defer m.mu.Unlock() + + t, ok := m.ipamTokens.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "ipam verification token %q not found", id) + } + + t.State = ipamStateDeleteComplete + + m.ipamTokens.Delete(id) + + out := cloneToken(t) + + return &out, nil +} + +// DescribeIpamExternalResourceVerificationTokens returns tokens matching ids. +func (m *Mock) DescribeIpamExternalResourceVerificationTokens( + _ context.Context, ids []string, +) ([]driver.IpamExternalResourceVerificationToken, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.ipamTokens, ids, cloneToken), nil +} + +func cloneResolver(r *driver.IpamPrefixListResolver) driver.IpamPrefixListResolver { + out := *r + out.Tags = copyTags(r.Tags) + + return out +} + +func cloneResolverTarget(t *driver.IpamPrefixListResolverTarget) driver.IpamPrefixListResolverTarget { + out := *t + out.Tags = copyTags(t.Tags) + + return out +} + +func cloneToken(t *driver.IpamExternalResourceVerificationToken) driver.IpamExternalResourceVerificationToken { + out := *t + out.Tags = copyTags(t.Tags) + + return out +} diff --git a/providers/aws/vpc/ipam_resources.go b/providers/aws/vpc/ipam_resources.go new file mode 100644 index 00000000..35760572 --- /dev/null +++ b/providers/aws/vpc/ipam_resources.go @@ -0,0 +1,202 @@ +package vpc + +import ( + "context" + "net" + "time" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +const ( + resourceTypeVPC = "vpc" + resourceTypeSubnet = "subnet" + + mgmtStateManaged = "managed" + mgmtStateUnmanaged = "unmanaged" +) + +// ipamResourceOverride records the caller-requested scope move / unmonitor for +// a tracked resource CIDR. The base resource-CIDR list is derived fresh from +// VPCs/subnets on every read, so ModifyIpamResourceCidr persists its changes +// here and ipamResourceCidrs applies them. +type ipamResourceOverride struct { + scopeID string + unmanaged bool +} + +// ipamResourceCidrs derives IPAM's tracked resource CIDRs from the VPCs and +// subnets held in this mock. IPAM "monitors" existing network resources, so in +// the emulator those are the stored VPCs/subnets. Caller holds at least RLock. +func (m *Mock) ipamResourceCidrs() []driver.IpamResourceCidr { + out := make([]driver.IpamResourceCidr, 0, m.vpcs.Len()+m.subnets.Len()) + + for _, v := range m.vpcs.SortedValues() { + out = append(out, driver.IpamResourceCidr{ + ResourceCIDR: v.CIDRBlock, ResourceID: v.ID, ResourceType: resourceTypeVPC, VPCID: v.ID, + ResourceRegion: m.opts.Region, ResourceOwnerID: m.opts.AccountID, + ComplianceStatus: "compliant", ManagementState: mgmtStateManaged, OverlapStatus: "nonoverlapping", + IPUsage: m.vpcIPUsage(v.ID, v.CIDRBlock), Tags: copyTags(v.Tags), + }) + } + + for _, s := range m.subnets.SortedValues() { + out = append(out, driver.IpamResourceCidr{ + ResourceCIDR: s.CIDRBlock, ResourceID: s.ID, ResourceType: resourceTypeSubnet, VPCID: s.VPCID, + ResourceRegion: m.opts.Region, ResourceOwnerID: m.opts.AccountID, AvailabilityZone: s.AvailabilityZone, + ComplianceStatus: "compliant", ManagementState: mgmtStateManaged, OverlapStatus: "nonoverlapping", + IPUsage: 0, Tags: copyTags(s.Tags), + }) + } + + // Apply any persisted scope-move / unmonitor overrides from + // ModifyIpamResourceCidr so Get/Describe/metrics reflect the change. + for i := range out { + ov, ok := m.ipamResourceOverrides[out[i].ResourceID] + if !ok { + continue + } + + if ov.scopeID != "" { + out[i].IpamScopeID = ov.scopeID + } + + if ov.unmanaged { + out[i].ManagementState = mgmtStateUnmanaged + } + } + + return out +} + +// vpcIPUsage returns the fraction of a VPC's IPv4 space covered by its subnets. +func (m *Mock) vpcIPUsage(vpcID, vpcCIDR string) float64 { + total := cidrSize(vpcCIDR) + if total == 0 { + return 0 + } + + var used float64 + + for _, s := range m.subnets.SortedValues() { + if s.VPCID == vpcID { + used += cidrSize(s.CIDRBlock) + } + } + + return used / total +} + +// cidrSize returns the number of IPv4 addresses in a CIDR, or 0 if unparseable. +func cidrSize(cidr string) float64 { + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil || ipnet.IP.To4() == nil { + return 0 + } + + ones, bits := ipnet.Mask.Size() + + return float64(uint64(1) << uint(bits-ones)) //nolint:gosec // bits-ones is 0..32, no overflow +} + +// GetIpamResourceCidrs returns the resource CIDRs IPAM tracks in a scope, +// optionally filtered to a single resource id. +func (m *Mock) GetIpamResourceCidrs(_ context.Context, scopeID, resourceID string) ([]driver.IpamResourceCidr, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if scopeID != "" && !m.ipamScopes.Has(scopeID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam scope %q not found", scopeID) + } + + all := m.ipamResourceCidrs() + if resourceID == "" { + return all, nil + } + + out := all[:0:0] + + for i := range all { + if all[i].ResourceID == resourceID { + out = append(out, all[i]) + } + } + + return out, nil +} + +// ModifyIpamResourceCidr adjusts monitoring/scope of a tracked resource CIDR. +// The emulator derives resource CIDRs from stored resources, so this echoes +// the requested state for the matching resource. +func (m *Mock) ModifyIpamResourceCidr( + _ context.Context, resourceID, currentScopeID, destScopeID string, monitored bool, +) (*driver.IpamResourceCidr, error) { + m.mu.Lock() + defer m.mu.Unlock() + + cidrs := m.ipamResourceCidrs() + for i := range cidrs { + if cidrs[i].ResourceID != resourceID { + continue + } + + scopeID := destScopeID + if scopeID == "" { + scopeID = currentScopeID + } + + // Persist the change so subsequent Get/Describe/metrics reads reflect + // it — the base list is re-derived every call, so a purely local edit + // would silently revert. + m.ipamResourceOverrides[resourceID] = ipamResourceOverride{ + scopeID: scopeID, + unmanaged: !monitored, + } + + c := cidrs[i] + if scopeID != "" { + c.IpamScopeID = scopeID + } + + if !monitored { + c.ManagementState = mgmtStateUnmanaged + } + + out := c + + return &out, nil + } + + return nil, errors.Newf(errors.NotFound, "resource %q not tracked by ipam", resourceID) +} + +// GetIpamAddressHistory returns history records for a CIDR within a scope. The +// emulator has a single sample window per currently-tracked resource. +func (m *Mock) GetIpamAddressHistory(_ context.Context, cidr, scopeID string) ([]driver.IpamAddressHistoryRecord, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if scopeID != "" && !m.ipamScopes.Has(scopeID) { + return nil, errors.Newf(errors.InvalidArgument, "ipam scope %q not found", scopeID) + } + + cidrs := m.ipamResourceCidrs() + out := make([]driver.IpamAddressHistoryRecord, 0, len(cidrs)) + + for i := range cidrs { + c := cidrs[i] + if cidr != "" && c.ResourceCIDR != cidr { + continue + } + + out = append(out, driver.IpamAddressHistoryRecord{ + ResourceCIDR: c.ResourceCIDR, ResourceID: c.ResourceID, ResourceType: c.ResourceType, + ResourceRegion: c.ResourceRegion, ResourceOwnerID: c.ResourceOwnerID, VPCID: c.VPCID, + ResourceComplianceStatus: c.ComplianceStatus, ResourceOverlapStatus: c.OverlapStatus, + SampledStartTime: time.Unix(0, 0).UTC(), SampledEndTime: time.Unix(0, 0).UTC(), + }) + } + + return out, nil +} diff --git a/providers/aws/vpc/networking_parity_test.go b/providers/aws/vpc/networking_parity_test.go new file mode 100644 index 00000000..778ff322 --- /dev/null +++ b/providers/aws/vpc/networking_parity_test.go @@ -0,0 +1,1041 @@ +package vpc + +import ( + "context" + "net" + "sync" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func newMock() *Mock { return New(config.NewOptions()) } + +func mustVPC(t *testing.T, m *Mock) (vpcID, subnetID string) { + t.Helper() + + ctx := context.Background() + + v, err := m.CreateVPC(ctx, driver.VPCConfig{CIDRBlock: "10.0.0.0/16"}) + if err != nil { + t.Fatalf("CreateVPC: %v", err) + } + + s, err := m.CreateSubnet(ctx, driver.SubnetConfig{VPCID: v.ID, CIDRBlock: "10.0.1.0/24"}) + if err != nil { + t.Fatalf("CreateSubnet: %v", err) + } + + return v.ID, s.ID +} + +func TestTransitGatewayLifecycle(t *testing.T) { + m := newMock() + ctx := context.Background() + vpcID, subnetID := mustVPC(t, m) + + tgw, err := m.CreateTransitGateway(ctx, driver.TransitGatewayConfig{Description: "hub"}) + if err != nil || tgw.ASN != defaultAmazonSideASN { + t.Fatalf("CreateTransitGateway: %v %+v", err, tgw) + } + + att, err := m.CreateTransitGatewayVPCAttachment(ctx, driver.TransitGatewayVPCAttachmentConfig{ + TransitGatewayID: tgw.ID, VPCID: vpcID, SubnetIDs: []string{subnetID}, + }) + if err != nil || att.VPCID != vpcID { + t.Fatalf("CreateTransitGatewayVPCAttachment: %v %+v", err, att) + } + + // Attachment to a missing transit gateway is rejected. + if _, err := m.CreateTransitGatewayVPCAttachment(ctx, driver.TransitGatewayVPCAttachmentConfig{ + TransitGatewayID: "tgw-nope", VPCID: vpcID, + }); !cerrors.IsInvalidArgument(err) { + t.Fatalf("attach to missing tgw: got %v, want InvalidArgument", err) + } + + rt, err := m.CreateTransitGatewayRouteTable(ctx, tgw.ID, nil) + if err != nil { + t.Fatalf("CreateTransitGatewayRouteTable: %v", err) + } + + // Routing depth: associate, add a route, search. + if _, err := m.AssociateTransitGatewayRouteTable(ctx, rt.ID, att.ID); err != nil { + t.Fatalf("AssociateTransitGatewayRouteTable: %v", err) + } + + if _, err := m.CreateTransitGatewayRoute(ctx, rt.ID, "10.9.0.0/16", att.ID); err != nil { + t.Fatalf("CreateTransitGatewayRoute: %v", err) + } + + routes, err := m.SearchTransitGatewayRoutes(ctx, rt.ID) + if err != nil || len(routes) != 1 || routes[0].DestinationCIDR != "10.9.0.0/16" { + t.Fatalf("SearchTransitGatewayRoutes: %v %+v", err, routes) + } + + if err := m.EnableTransitGatewayRouteTablePropagation(ctx, rt.ID, att.ID); err != nil { + t.Fatalf("EnableTransitGatewayRouteTablePropagation: %v", err) + } + + if _, err := m.DeleteTransitGatewayRoute(ctx, rt.ID, "10.9.0.0/16"); err != nil { + t.Fatalf("DeleteTransitGatewayRoute: %v", err) + } + + // A route in a missing route table is rejected. + if _, err := m.CreateTransitGatewayRoute(ctx, "tgw-rtb-nope", "10.0.0.0/8", att.ID); !cerrors.IsInvalidArgument(err) { + t.Fatalf("route in missing table: got %v, want InvalidArgument", err) + } + + if _, err := m.DeleteTransitGatewayRouteTable(ctx, rt.ID); err != nil { + t.Fatalf("DeleteTransitGatewayRouteTable: %v", err) + } + + // A transit gateway with a live attachment cannot be deleted. + if _, err := m.DeleteTransitGateway(ctx, tgw.ID); !cerrors.IsFailedPrecondition(err) { + t.Fatalf("delete in-use tgw: got %v, want FailedPrecondition", err) + } + + if _, err := m.DeleteTransitGatewayVPCAttachment(ctx, att.ID); err != nil { + t.Fatalf("DeleteTransitGatewayVPCAttachment: %v", err) + } + + if _, err := m.DeleteTransitGateway(ctx, tgw.ID); err != nil { + t.Fatalf("DeleteTransitGateway: %v", err) + } + + if got, _ := m.DescribeTransitGateways(ctx, nil); len(got) != 0 { + t.Fatalf("transit gateway survived delete: %+v", got) + } +} + +func TestVPNLifecycle(t *testing.T) { + m := newMock() + ctx := context.Background() + vpcID, _ := mustVPC(t, m) + + if _, err := m.CreateCustomerGateway(ctx, driver.CustomerGatewayConfig{}); !cerrors.IsInvalidArgument(err) { + t.Fatalf("customer gateway without IP: got %v, want InvalidArgument", err) + } + + cgw, err := m.CreateCustomerGateway(ctx, driver.CustomerGatewayConfig{IPAddress: "203.0.113.10", BGPASN: 65000}) + if err != nil || cgw.Type != "ipsec.1" { + t.Fatalf("CreateCustomerGateway: %v %+v", err, cgw) + } + + vgw, err := m.CreateVPNGateway(ctx, driver.VPNGatewayConfig{}) + if err != nil { + t.Fatalf("CreateVPNGateway: %v", err) + } + + if _, err := m.AttachVPNGateway(ctx, vgw.ID, vpcID); err != nil { + t.Fatalf("AttachVPNGateway: %v", err) + } + + got, _ := m.DescribeVPNGateways(ctx, []string{vgw.ID}) + if len(got) != 1 || got[0].AttachedVPCID != vpcID { + t.Fatalf("attached vgw wrong: %+v", got) + } + + vpn, err := m.CreateVPNConnection(ctx, driver.VPNConnectionConfig{CustomerGatewayID: cgw.ID, VPNGatewayID: vgw.ID}) + if err != nil { + t.Fatalf("CreateVPNConnection: %v", err) + } + + // A connection with neither gateway is rejected. + if _, err := m.CreateVPNConnection(ctx, driver.VPNConnectionConfig{CustomerGatewayID: cgw.ID}); !cerrors.IsInvalidArgument(err) { + t.Fatalf("vpn connection without gateway: got %v, want InvalidArgument", err) + } + + // Static route depth. + if err := m.CreateVPNConnectionRoute(ctx, vpn.ID, "192.168.0.0/16"); err != nil { + t.Fatalf("CreateVPNConnectionRoute: %v", err) + } + + // Duplicate route is a no-op. + if err := m.CreateVPNConnectionRoute(ctx, vpn.ID, "192.168.0.0/16"); err != nil { + t.Fatalf("CreateVPNConnectionRoute dup: %v", err) + } + + gotVPN, _ := m.DescribeVPNConnections(ctx, []string{vpn.ID}) + if len(gotVPN) != 1 || len(gotVPN[0].Routes) != 1 { + t.Fatalf("vpn routes wrong: %+v", gotVPN) + } + + // Re-target to a transit gateway clears the vpn gateway. + tgw, err := m.CreateTransitGateway(ctx, driver.TransitGatewayConfig{}) + if err != nil { + t.Fatalf("CreateTransitGateway: %v", err) + } + + // Modify rejects a nonexistent gateway. + if _, err := m.ModifyVPNConnection(ctx, vpn.ID, "tgw-nope", ""); !cerrors.IsInvalidArgument(err) { + t.Fatalf("modify to missing tgw: got %v, want InvalidArgument", err) + } + + mod, err := m.ModifyVPNConnection(ctx, vpn.ID, tgw.ID, "") + if err != nil || mod.TransitGatewayID != tgw.ID || mod.VPNGatewayID != "" { + t.Fatalf("ModifyVPNConnection: %v %+v", err, mod) + } + + if err := m.DeleteVPNConnectionRoute(ctx, vpn.ID, "192.168.0.0/16"); err != nil { + t.Fatalf("DeleteVPNConnectionRoute: %v", err) + } + + if err := m.DetachVPNGateway(ctx, vgw.ID, vpcID); err != nil { + t.Fatalf("DetachVPNGateway: %v", err) + } + + if err := m.DeleteVPNConnection(ctx, vpn.ID); err != nil { + t.Fatalf("DeleteVPNConnection: %v", err) + } + + // Delete the gateways and confirm the read-back is empty. + if err := m.DeleteVPNGateway(ctx, vgw.ID); err != nil { + t.Fatalf("DeleteVPNGateway: %v", err) + } + + if err := m.DeleteCustomerGateway(ctx, cgw.ID); err != nil { + t.Fatalf("DeleteCustomerGateway: %v", err) + } + + if got, _ := m.DescribeCustomerGateways(ctx, nil); len(got) != 0 { + t.Fatalf("customer gateway survived delete: %+v", got) + } + + if got, _ := m.DescribeVPNGateways(ctx, nil); len(got) != 0 { + t.Fatalf("vpn gateway survived delete: %+v", got) + } + + // Deleting a missing connection is a NotFound. + if err := m.DeleteVPNConnection(ctx, "vpn-nope"); !cerrors.IsNotFound(err) { + t.Fatalf("delete missing vpn: got %v, want NotFound", err) + } +} + +func TestDHCPPrefixEgressEndpointClientVPN(t *testing.T) { + m := newMock() + ctx := context.Background() + vpcID, subnetID := mustVPC(t, m) + + // DHCP options. + opt, err := m.CreateDHCPOptions(ctx, driver.DHCPOptionsConfig{ + Configuration: map[string][]string{"domain-name-servers": {"10.0.0.2"}}, + }) + if err != nil { + t.Fatalf("CreateDHCPOptions: %v", err) + } + + if err := m.AssociateDHCPOptions(ctx, opt.ID, vpcID); err != nil { + t.Fatalf("AssociateDHCPOptions: %v", err) + } + + if err := m.AssociateDHCPOptions(ctx, "default", vpcID); err != nil { + t.Fatalf("AssociateDHCPOptions default: %v", err) + } + + // Prefix list. + if _, err := m.CreateManagedPrefixList(ctx, driver.PrefixListConfig{Name: "x"}); !cerrors.IsInvalidArgument(err) { + t.Fatalf("prefix list without maxEntries: got %v, want InvalidArgument", err) + } + + pl, err := m.CreateManagedPrefixList(ctx, driver.PrefixListConfig{ + Name: "corp", MaxEntries: 10, Entries: []driver.PrefixListEntry{{CIDR: "10.0.0.0/8"}}, + }) + if err != nil { + t.Fatalf("CreateManagedPrefixList: %v", err) + } + + entries, _ := m.GetManagedPrefixListEntries(ctx, pl.ID) + if len(entries) != 1 { + t.Fatalf("prefix list entries: %+v", entries) + } + + // Modify: swap the entry, version bumps. + mod, err := m.ModifyManagedPrefixList(ctx, pl.ID, + []driver.PrefixListEntry{{CIDR: "172.16.0.0/12"}}, []string{"10.0.0.0/8"}) + if err != nil || mod.Version != 2 { + t.Fatalf("ModifyManagedPrefixList: %v %+v", err, mod) + } + + entries2, _ := m.GetManagedPrefixListEntries(ctx, pl.ID) + if len(entries2) != 1 || entries2[0].CIDR != "172.16.0.0/12" { + t.Fatalf("prefix list after modify: %+v", entries2) + } + + // Egress-only IGW. + if _, err := m.CreateEgressOnlyInternetGateway(ctx, "vpc-nope", nil); !cerrors.IsInvalidArgument(err) { + t.Fatalf("egress-only under missing vpc: got %v, want InvalidArgument", err) + } + + eigw, err := m.CreateEgressOnlyInternetGateway(ctx, vpcID, nil) + if err != nil || eigw.AttachedVPCID != vpcID { + t.Fatalf("CreateEgressOnlyInternetGateway: %v %+v", err, eigw) + } + + // Endpoint service. + if _, err := m.CreateVPCEndpointServiceConfiguration(ctx, driver.EndpointServiceConfig{}); !cerrors.IsInvalidArgument(err) { + t.Fatalf("endpoint service without NLB: got %v, want InvalidArgument", err) + } + + svc, err := m.CreateVPCEndpointServiceConfiguration(ctx, driver.EndpointServiceConfig{ + NetworkLoadBalancerARNs: []string{"arn:aws:elasticloadbalancing:us-east-1:0:loadbalancer/net/x/1"}, + }) + if err != nil || svc.ServiceName == "" { + t.Fatalf("CreateVPCEndpointServiceConfiguration: %v %+v", err, svc) + } + + // Endpoint-service permissions: add then remove. + if err := m.ModifyVPCEndpointServicePermissions(ctx, svc.ID, + []string{"arn:aws:iam::111122223333:root", "arn:aws:iam::444455556666:root"}, nil); err != nil { + t.Fatalf("ModifyVPCEndpointServicePermissions add: %v", err) + } + + perms, _ := m.DescribeVPCEndpointServicePermissions(ctx, svc.ID) + if len(perms) != 2 { + t.Fatalf("endpoint service perms after add: %+v", perms) + } + + if err := m.ModifyVPCEndpointServicePermissions(ctx, svc.ID, + nil, []string{"arn:aws:iam::444455556666:root"}); err != nil { + t.Fatalf("ModifyVPCEndpointServicePermissions remove: %v", err) + } + + perms, _ = m.DescribeVPCEndpointServicePermissions(ctx, svc.ID) + if len(perms) != 1 || perms[0] != "arn:aws:iam::111122223333:root" { + t.Fatalf("endpoint service perms after remove: %+v", perms) + } + + // Client VPN. Authentication options are required. + if _, err := m.CreateClientVPNEndpoint(ctx, driver.ClientVPNEndpointConfig{ + ClientCIDRBlock: "10.100.0.0/16", ServerCertificateARN: "arn:aws:acm:us-east-1:0:certificate/abc", + }); !cerrors.IsInvalidArgument(err) { + t.Fatalf("client vpn without auth: got %v, want InvalidArgument", err) + } + + ep, err := m.CreateClientVPNEndpoint(ctx, driver.ClientVPNEndpointConfig{ + ClientCIDRBlock: "10.100.0.0/16", ServerCertificateARN: "arn:aws:acm:us-east-1:0:certificate/abc", + AuthenticationTypes: []string{"certificate-authentication"}, + }) + if err != nil || ep.State != "pending-associate" { + t.Fatalf("CreateClientVPNEndpoint: %v %+v", err, ep) + } + + assoc, err := m.AssociateClientVPNTargetNetwork(ctx, ep.ID, subnetID) + if err != nil { + t.Fatalf("AssociateClientVPNTargetNetwork: %v", err) + } + + got, _ := m.DescribeClientVPNEndpoints(ctx, []string{ep.ID}) + if len(got) != 1 || got[0].State != "available" { + t.Fatalf("client vpn not available after associate: %+v", got) + } + + nets, err := m.DescribeClientVPNTargetNetworks(ctx, ep.ID) + if err != nil || len(nets) != 1 { + t.Fatalf("DescribeClientVPNTargetNetworks: %v %+v", err, nets) + } + + // Authorization rules. + if _, err := m.AuthorizeClientVPNIngress(ctx, ep.ID, "10.0.0.0/16", "", true); err != nil { + t.Fatalf("AuthorizeClientVPNIngress: %v", err) + } + + rules, _ := m.DescribeClientVPNAuthorizationRules(ctx, ep.ID) + if len(rules) != 1 || rules[0].TargetCIDR != "10.0.0.0/16" { + t.Fatalf("auth rules: %+v", rules) + } + + if err := m.RevokeClientVPNIngress(ctx, ep.ID, "10.0.0.0/16"); err != nil { + t.Fatalf("RevokeClientVPNIngress: %v", err) + } + + if rules, _ := m.DescribeClientVPNAuthorizationRules(ctx, ep.ID); len(rules) != 0 { + t.Fatalf("auth rule survived revoke: %+v", rules) + } + + // Routes. + if _, err := m.CreateClientVPNRoute(ctx, ep.ID, "0.0.0.0/0", subnetID); err != nil { + t.Fatalf("CreateClientVPNRoute: %v", err) + } + + routes, _ := m.DescribeClientVPNRoutes(ctx, ep.ID) + if len(routes) != 1 || routes[0].DestinationCIDR != "0.0.0.0/0" { + t.Fatalf("client vpn routes: %+v", routes) + } + + if err := m.DeleteClientVPNRoute(ctx, ep.ID, "0.0.0.0/0", subnetID); err != nil { + t.Fatalf("DeleteClientVPNRoute: %v", err) + } + + if err := m.DisassociateClientVPNTargetNetwork(ctx, ep.ID, assoc.AssociationID); err != nil { + t.Fatalf("DisassociateClientVPNTargetNetwork: %v", err) + } + + // Delete the remaining resources and confirm the read-back halves. + if _, err := m.DeleteManagedPrefixList(ctx, pl.ID); err != nil { + t.Fatalf("DeleteManagedPrefixList: %v", err) + } + + if got, _ := m.DescribeManagedPrefixLists(ctx, nil); len(got) != 0 { + t.Fatalf("prefix list survived delete: %+v", got) + } + + if err := m.DeleteEgressOnlyInternetGateway(ctx, eigw.ID); err != nil { + t.Fatalf("DeleteEgressOnlyInternetGateway: %v", err) + } + + if err := m.DeleteVPCEndpointServiceConfiguration(ctx, svc.ID); err != nil { + t.Fatalf("DeleteVPCEndpointServiceConfiguration: %v", err) + } + + if got, _ := m.DescribeVPCEndpointServiceConfigurations(ctx, nil); len(got) != 0 { + t.Fatalf("endpoint service survived delete: %+v", got) + } + + if err := m.DeleteClientVPNEndpoint(ctx, ep.ID); err != nil { + t.Fatalf("DeleteClientVPNEndpoint: %v", err) + } + + if got, _ := m.DescribeClientVPNEndpoints(ctx, nil); len(got) != 0 { + t.Fatalf("client vpn endpoint survived delete: %+v", got) + } +} + +// TestNetworkingConcurrentReadWrite exercises the reader RLock vs mutator Lock +// discipline under the race detector: an in-place VPN-gateway mutation +// (Attach/Detach) must not race a concurrent Describe. Meaningful with -race. +func TestNetworkingConcurrentReadWrite(t *testing.T) { + m := newMock() + ctx := context.Background() + vpcID, _ := mustVPC(t, m) + + vgw, err := m.CreateVPNGateway(ctx, driver.VPNGatewayConfig{}) + if err != nil { + t.Fatalf("CreateVPNGateway: %v", err) + } + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(2) + + go func() { + defer wg.Done() + + _, _ = m.AttachVPNGateway(ctx, vgw.ID, vpcID) + _ = m.DetachVPNGateway(ctx, vgw.ID, vpcID) + }() + + go func() { + defer wg.Done() + + _, _ = m.DescribeVPNGateways(ctx, []string{vgw.ID}) + }() + } + + wg.Wait() +} + +func TestIPAMLifecycle(t *testing.T) { + m := newMock() + ctx := context.Background() + + ipam, err := m.CreateIpam(ctx, driver.IpamConfig{Description: "corp"}) + if err != nil || ipam.PublicDefaultScopeID == "" || ipam.PrivateDefaultScopeID == "" || ipam.ScopeCount != 2 { + t.Fatalf("CreateIpam: %v %+v", err, ipam) + } + + // Default scopes are discoverable. + if got, _ := m.DescribeIpamScopes(ctx, nil); len(got) != 2 { + t.Fatalf("default scopes: %+v", got) + } + + // Creating a pool in a missing scope is rejected. + if _, err := m.CreateIpamPool(ctx, driver.IpamPoolConfig{IpamScopeID: "ipam-scope-nope"}); !cerrors.IsInvalidArgument(err) { + t.Fatalf("pool in missing scope: got %v, want InvalidArgument", err) + } + + pool, err := m.CreateIpamPool(ctx, driver.IpamPoolConfig{IpamScopeID: ipam.PrivateDefaultScopeID, AddressFamily: "ipv4"}) + if err != nil || pool.State != "create-complete" { + t.Fatalf("CreateIpamPool: %v %+v", err, pool) + } + + // Provision supply, allocate, read back. + if _, err := m.ProvisionIpamPoolCidr(ctx, pool.ID, "10.0.0.0/16", 0); err != nil { + t.Fatalf("ProvisionIpamPoolCidr: %v", err) + } + + if cidrs, _ := m.GetIpamPoolCidrs(ctx, pool.ID); len(cidrs) != 1 || cidrs[0].CIDR != "10.0.0.0/16" { + t.Fatalf("GetIpamPoolCidrs: %+v", cidrs) + } + + alloc, err := m.AllocateIpamPoolCidr(ctx, driver.AllocateIpamPoolCidrConfig{IpamPoolID: pool.ID, CIDR: "10.0.1.0/24"}) + if err != nil { + t.Fatalf("AllocateIpamPoolCidr: %v", err) + } + + // Netmask-only allocation (the standard AWS pattern) must derive a real, + // non-empty CIDR of the requested size from the pool's supply, not the + // empty string — otherwise downstream reads and AWS/IPAM metrics corrupt. + nmAlloc, err := m.AllocateIpamPoolCidr(ctx, driver.AllocateIpamPoolCidrConfig{IpamPoolID: pool.ID, NetmaskLength: 24}) + if err != nil { + t.Fatalf("AllocateIpamPoolCidr(netmask): %v", err) + } + + if _, ipnet, perr := net.ParseCIDR(nmAlloc.CIDR); perr != nil { + t.Fatalf("netmask-only allocation returned invalid CIDR %q: %v", nmAlloc.CIDR, perr) + } else if ones, _ := ipnet.Mask.Size(); ones != 24 { + t.Fatalf("derived CIDR %q is not a /24", nmAlloc.CIDR) + } + + if nmAlloc.CIDR == "10.0.1.0/24" { + t.Fatalf("derived CIDR overlaps the existing allocation: %q", nmAlloc.CIDR) + } + + // Netmask-only provisioning likewise derives a concrete CIDR. + nmProv, err := m.ProvisionIpamPoolCidr(ctx, pool.ID, "", 24) + if err != nil { + t.Fatalf("ProvisionIpamPoolCidr(netmask): %v", err) + } + + if nmProv.CIDR == "" { + t.Fatal("netmask-only provisioning returned an empty CIDR") + } + + // Restore the pool to a single provisioned CIDR + single allocation so the + // assertions below (delete-guard, allocation count) still hold. + if err := m.ReleaseIpamPoolAllocation(ctx, pool.ID, nmAlloc.ID); err != nil { + t.Fatalf("ReleaseIpamPoolAllocation(netmask): %v", err) + } + + if _, err := m.DeprovisionIpamPoolCidr(ctx, pool.ID, nmProv.CIDR); err != nil { + t.Fatalf("DeprovisionIpamPoolCidr(netmask): %v", err) + } + + // A pool with a live allocation cannot be deleted. + if _, err := m.DeleteIpamPool(ctx, pool.ID); !cerrors.IsFailedPrecondition(err) { + t.Fatalf("delete pool with allocation: got %v, want FailedPrecondition", err) + } + + if allocs, _ := m.GetIpamPoolAllocations(ctx, pool.ID); len(allocs) != 1 { + t.Fatalf("GetIpamPoolAllocations: %+v", allocs) + } + + if _, err := m.ModifyIpamPoolAllocation(ctx, alloc.ID, "updated"); err != nil { + t.Fatalf("ModifyIpamPoolAllocation: %v", err) + } + + if err := m.ReleaseIpamPoolAllocation(ctx, pool.ID, alloc.ID); err != nil { + t.Fatalf("ReleaseIpamPoolAllocation: %v", err) + } + + // Releasing again is NotFound. + if err := m.ReleaseIpamPoolAllocation(ctx, pool.ID, alloc.ID); !cerrors.IsNotFound(err) { + t.Fatalf("release twice: got %v, want NotFound", err) + } + + if _, err := m.DeprovisionIpamPoolCidr(ctx, pool.ID, "10.0.0.0/16"); err != nil { + t.Fatalf("DeprovisionIpamPoolCidr: %v", err) + } + + if _, err := m.DeleteIpamPool(ctx, pool.ID); err != nil { + t.Fatalf("DeleteIpamPool: %v", err) + } + + // An IPAM with a non-default scope cannot be deleted until the scope is gone. + extra, err := m.CreateIpamScope(ctx, driver.IpamScopeConfig{IpamID: ipam.ID}) + if err != nil { + t.Fatalf("CreateIpamScope: %v", err) + } + + if _, err := m.DeleteIpam(ctx, ipam.ID); !cerrors.IsFailedPrecondition(err) { + t.Fatalf("delete ipam with scope: got %v, want FailedPrecondition", err) + } + + if _, err := m.DeleteIpamScope(ctx, extra.ID); err != nil { + t.Fatalf("DeleteIpamScope: %v", err) + } + + if _, err := m.DeleteIpam(ctx, ipam.ID); err != nil { + t.Fatalf("DeleteIpam: %v", err) + } + + if got, _ := m.DescribeIpams(ctx, nil); len(got) != 0 { + t.Fatalf("ipam survived delete: %+v", got) + } +} + +// TestIPAMProvisionNoCrossPoolOverlap guards the review fix: two top-level +// pools each provisioning a netmask-only /16 must receive DISTINCT, +// non-overlapping CIDRs — both carve from the same shared base, so the +// second must skip the first's block instead of both getting 10.0.0.0/16. +func TestIPAMProvisionNoCrossPoolOverlap(t *testing.T) { + m := newMock() + ctx := context.Background() + + ip, err := m.CreateIpam(ctx, driver.IpamConfig{}) + if err != nil { + t.Fatalf("CreateIpam: %v", err) + } + + poolA, err := m.CreateIpamPool(ctx, driver.IpamPoolConfig{IpamScopeID: ip.PrivateDefaultScopeID, AddressFamily: "ipv4"}) + if err != nil { + t.Fatalf("CreateIpamPool A: %v", err) + } + + poolB, err := m.CreateIpamPool(ctx, driver.IpamPoolConfig{IpamScopeID: ip.PrivateDefaultScopeID, AddressFamily: "ipv4"}) + if err != nil { + t.Fatalf("CreateIpamPool B: %v", err) + } + + a, err := m.ProvisionIpamPoolCidr(ctx, poolA.ID, "", 16) + if err != nil { + t.Fatalf("Provision A: %v", err) + } + + b, err := m.ProvisionIpamPoolCidr(ctx, poolB.ID, "", 16) + if err != nil { + t.Fatalf("Provision B: %v", err) + } + + if a.CIDR == b.CIDR { + t.Fatalf("cross-pool overlap: both pools provisioned %q", a.CIDR) + } + + sa, ea, _ := ipv4Range(a.CIDR) + sb, eb, _ := ipv4Range(b.CIDR) + + if sa <= eb && sb <= ea { + t.Fatalf("provisioned CIDRs overlap: %q and %q", a.CIDR, b.CIDR) + } +} + +// TestIPAMConcurrentAccess exercises the IPAM locking under -race. +func TestIPAMConcurrentAccess(t *testing.T) { + m := newMock() + ctx := context.Background() + + ipam, err := m.CreateIpam(ctx, driver.IpamConfig{}) + if err != nil { + t.Fatalf("CreateIpam: %v", err) + } + + pool, err := m.CreateIpamPool(ctx, driver.IpamPoolConfig{IpamScopeID: ipam.PrivateDefaultScopeID}) + if err != nil { + t.Fatalf("CreateIpamPool: %v", err) + } + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(2) + + go func() { + defer wg.Done() + + _, _ = m.ProvisionIpamPoolCidr(ctx, pool.ID, "10.0.0.0/16", 0) + _, _ = m.ModifyIpamPool(ctx, pool.ID, "x") + }() + + go func() { + defer wg.Done() + + _, _ = m.GetIpamPoolCidrs(ctx, pool.ID) + _, _ = m.DescribeIpamPools(ctx, []string{pool.ID}) + }() + } + + wg.Wait() +} + +func TestIPAMFullProvider(t *testing.T) { + m := newMock() + ctx := context.Background() + + // Seed a VPC + subnet so resource CIDRs / discovery / metrics have input. + v, _ := m.CreateVPC(ctx, driver.VPCConfig{CIDRBlock: "10.0.0.0/16"}) + _, _ = m.CreateSubnet(ctx, driver.SubnetConfig{VPCID: v.ID, CIDRBlock: "10.0.0.0/24"}) + + ipam, err := m.CreateIpam(ctx, driver.IpamConfig{}) + if err != nil || ipam.DefaultResourceDiscoveryID == "" || ipam.ResourceDiscoveryAssociationCount != 1 { + t.Fatalf("CreateIpam default RD: %v %+v", err, ipam) + } + + // Resource CIDRs + history derive from the VPC/subnet. + rc, err := m.GetIpamResourceCidrs(ctx, ipam.PrivateDefaultScopeID, "") + if err != nil || len(rc) != 2 { + t.Fatalf("GetIpamResourceCidrs: %v %+v", err, rc) + } + + // ModifyIpamResourceCidr must PERSIST: unmonitoring a resource has to + // survive the next (freshly-derived) read, not silently revert. + if _, err := m.ModifyIpamResourceCidr(ctx, rc[0].ResourceID, ipam.PrivateDefaultScopeID, "", false); err != nil { + t.Fatalf("ModifyIpamResourceCidr: %v", err) + } + + after, err := m.GetIpamResourceCidrs(ctx, ipam.PrivateDefaultScopeID, rc[0].ResourceID) + if err != nil || len(after) != 1 { + t.Fatalf("GetIpamResourceCidrs(after modify): %v %+v", err, after) + } + + if after[0].ManagementState != "unmanaged" { + t.Fatalf("unmonitor did not persist: ManagementState=%q, want unmanaged", after[0].ManagementState) + } + + if hist, _ := m.GetIpamAddressHistory(ctx, "10.0.0.0/16", ""); len(hist) != 1 { + t.Fatalf("GetIpamAddressHistory: %+v", hist) + } + + // Resource discovery: default RD is discoverable + discovered getters work. + if rds, _ := m.DescribeIpamResourceDiscoveries(ctx, nil); len(rds) != 1 { + t.Fatalf("DescribeIpamResourceDiscoveries: %+v", rds) + } + + if accts, _ := m.GetIpamDiscoveredAccounts(ctx, ipam.DefaultResourceDiscoveryID, ""); len(accts) != 1 { + t.Fatalf("GetIpamDiscoveredAccounts: %+v", accts) + } + + if dcidrs, _ := m.GetIpamDiscoveredResourceCidrs(ctx, ipam.DefaultResourceDiscoveryID, ""); len(dcidrs) != 2 { + t.Fatalf("GetIpamDiscoveredResourceCidrs: %+v", dcidrs) + } + + // A non-default resource discovery can be created + deleted. + rd, err := m.CreateIpamResourceDiscovery(ctx, driver.IpamResourceDiscoveryConfig{Description: "extra"}) + if err != nil { + t.Fatalf("CreateIpamResourceDiscovery: %v", err) + } + + if _, err := m.DeleteIpamResourceDiscovery(ctx, rd.ID); err != nil { + t.Fatalf("DeleteIpamResourceDiscovery: %v", err) + } + + // BYOASN + BYOIP. + if _, err := m.ProvisionIpamByoasn(ctx, ipam.ID, "64512"); err != nil { + t.Fatalf("ProvisionIpamByoasn: %v", err) + } + + if _, err := m.ProvisionByoipCidr(ctx, "203.0.113.0/24", "byoip"); err != nil { + t.Fatalf("ProvisionByoipCidr: %v", err) + } + + if _, err := m.AdvertiseByoipCidr(ctx, "203.0.113.0/24"); err != nil { + t.Fatalf("AdvertiseByoipCidr: %v", err) + } + + // BYOASN association validates both sides: a provisioned CIDR associates, + // an unprovisioned one is rejected, and disassociating an unknown ASN errs. + if _, err := m.AssociateIpamByoasn(ctx, "64512", "203.0.113.0/24"); err != nil { + t.Fatalf("AssociateIpamByoasn: %v", err) + } + + if _, err := m.AssociateIpamByoasn(ctx, "64512", "198.51.100.0/24"); !cerrors.IsInvalidArgument(err) { + t.Fatalf("associate unprovisioned cidr: got %v, want InvalidArgument", err) + } + + if _, err := m.DisassociateIpamByoasn(ctx, "64999", "203.0.113.0/24"); !cerrors.IsInvalidArgument(err) { + t.Fatalf("disassociate unknown asn: got %v, want InvalidArgument", err) + } + + if _, err := m.DisassociateIpamByoasn(ctx, "64512", "203.0.113.0/24"); err != nil { + t.Fatalf("DisassociateIpamByoasn: %v", err) + } + + // Advertised CIDR cannot be deprovisioned. + if _, err := m.DeprovisionByoipCidr(ctx, "203.0.113.0/24"); !cerrors.IsFailedPrecondition(err) { + t.Fatalf("deprovision advertised: got %v, want FailedPrecondition", err) + } + + // Prefix-list resolver + target + verification token. + res, err := m.CreateIpamPrefixListResolver(ctx, ipam.ID, "ipv4", "res", nil) + if err != nil { + t.Fatalf("CreateIpamPrefixListResolver: %v", err) + } + + // The target must reference an existing managed prefix list. + targetPL, err := m.CreateManagedPrefixList(ctx, driver.PrefixListConfig{ + Name: "resolver-target", MaxEntries: 5, Entries: []driver.PrefixListEntry{{CIDR: "10.1.0.0/16"}}, + }) + if err != nil { + t.Fatalf("CreateManagedPrefixList: %v", err) + } + + if _, err := m.CreateIpamPrefixListResolverTarget(ctx, res.ID, targetPL.ID, "", 1, true, nil); err != nil { + t.Fatalf("CreateIpamPrefixListResolverTarget: %v", err) + } + + // A target referencing a non-existent prefix list is rejected. + if _, err := m.CreateIpamPrefixListResolverTarget(ctx, res.ID, "pl-does-not-exist", "", 1, true, nil); !cerrors.IsInvalidArgument(err) { + t.Fatalf("target with unknown prefix list: got %v, want InvalidArgument", err) + } + + // Resolver with a target cannot be deleted. + if _, err := m.DeleteIpamPrefixListResolver(ctx, res.ID); !cerrors.IsFailedPrecondition(err) { + t.Fatalf("delete resolver with target: got %v, want FailedPrecondition", err) + } + + if _, err := m.CreateIpamExternalResourceVerificationToken(ctx, ipam.ID, "tok", nil); err != nil { + t.Fatalf("CreateIpamExternalResourceVerificationToken: %v", err) + } + + // Policy + org admin. + pol, err := m.CreateIpamPolicy(ctx, ipam.ID, nil) + if err != nil { + t.Fatalf("CreateIpamPolicy: %v", err) + } + + if _, err := m.EnableIpamPolicy(ctx, pol.ID, ""); err != nil { + t.Fatalf("EnableIpamPolicy: %v", err) + } + + if id, enabled, _, _ := m.GetEnabledIpamPolicy(ctx); !enabled || id != pol.ID { + t.Fatalf("GetEnabledIpamPolicy: id=%q enabled=%v", id, enabled) + } + + if ok, err := m.EnableIpamOrganizationAdminAccount(ctx, "111122223333"); err != nil || !ok { + t.Fatalf("EnableIpamOrganizationAdminAccount: %v %v", ok, err) + } + + // Metrics derive from state: TotalActiveIpCount + VpcIPUsage present. + metrics := m.IpamMetrics(ctx) + seen := map[string]bool{} + for _, mtr := range metrics { + seen[mtr.MetricName] = true + } + + if !seen["TotalActiveIpCount"] || !seen["VpcIPUsage"] || !seen["ManagedResourceCidrs"] { + t.Fatalf("expected IPAM metrics, got %v", seen) + } + + // Pool metrics appear once a pool with supply + an allocation exists. + pool, _ := m.CreateIpamPool(ctx, driver.IpamPoolConfig{IpamScopeID: ipam.PrivateDefaultScopeID}) + _, _ = m.ProvisionIpamPoolCidr(ctx, pool.ID, "10.1.0.0/16", 0) + _, _ = m.AllocateIpamPoolCidr(ctx, driver.AllocateIpamPoolCidrConfig{IpamPoolID: pool.ID, CIDR: "10.1.1.0/24"}) + + seen = map[string]bool{} + for _, mtr := range m.IpamMetrics(ctx) { + seen[mtr.MetricName] = true + } + + if !seen["PercentAssigned"] || !seen["PercentAvailable"] { + t.Fatalf("expected pool metrics, got %v", seen) + } +} + +func TestTrafficMirroringLifecycle(t *testing.T) { + m := newMock() + ctx := context.Background() + + target, err := m.CreateTrafficMirrorTarget(ctx, driver.TrafficMirrorTargetConfig{ + NetworkInterfaceID: "eni-123", Description: "t", Tags: map[string]string{"Name": "tm"}, + }) + if err != nil || target.ID == "" || target.Type != "network-interface" { + t.Fatalf("CreateTrafficMirrorTarget: %v %+v", err, target) + } + + filter, err := m.CreateTrafficMirrorFilter(ctx, "f", nil) + if err != nil || filter.ID == "" { + t.Fatalf("CreateTrafficMirrorFilter: %v %+v", err, filter) + } + + rule, err := m.CreateTrafficMirrorFilterRule(ctx, driver.TrafficMirrorFilterRuleConfig{ + FilterID: filter.ID, TrafficDirection: "ingress", RuleNumber: 100, RuleAction: "accept", + Protocol: 6, SourceCIDR: "0.0.0.0/0", DestinationCIDR: "10.0.0.0/16", + DestinationPortRange: &driver.TrafficMirrorPortRange{FromPort: 80, ToPort: 80}, + }) + if err != nil || rule.ID == "" || rule.RuleAction != "accept" { + t.Fatalf("CreateTrafficMirrorFilterRule: %v %+v", err, rule) + } + + rules, err := m.DescribeTrafficMirrorFilterRules(ctx, filter.ID, nil) + if err != nil || len(rules) != 1 || rules[0].DestinationPortRange == nil { + t.Fatalf("DescribeTrafficMirrorFilterRules: %v %+v", err, rules) + } + + mod, err := m.ModifyTrafficMirrorFilterRule(ctx, rule.ID, + driver.TrafficMirrorFilterRuleConfig{RuleAction: "reject"}, []string{"destination-port-range"}) + if err != nil || mod.RuleAction != "reject" || mod.DestinationPortRange != nil { + t.Fatalf("ModifyTrafficMirrorFilterRule: %v %+v", err, mod) + } + + session, err := m.CreateTrafficMirrorSession(ctx, driver.TrafficMirrorSessionConfig{ + NetworkInterfaceID: "eni-999", TrafficMirrorTargetID: target.ID, + TrafficMirrorFilterID: filter.ID, SessionNumber: 1, + }) + if err != nil || session.ID == "" || session.VirtualNetworkID != defaultVirtualNetworkID { + t.Fatalf("CreateTrafficMirrorSession: %v %+v", err, session) + } + + if _, err := m.CreateTrafficMirrorSession(ctx, driver.TrafficMirrorSessionConfig{ + TrafficMirrorTargetID: "tmt-missing", TrafficMirrorFilterID: filter.ID, SessionNumber: 1, + }); !cerrors.IsNotFound(err) { + t.Fatalf("expected NotFound for missing target, got %v", err) + } + + // Modify must re-validate a re-pointed target/filter, matching Create — a + // Modify can't bind a live session to a nonexistent target or filter. + if _, err := m.ModifyTrafficMirrorSession(ctx, session.ID, + driver.TrafficMirrorSessionConfig{TrafficMirrorTargetID: "tmt-missing"}, nil); !cerrors.IsNotFound(err) { + t.Fatalf("expected NotFound modifying session to missing target, got %v", err) + } + + if _, err := m.ModifyTrafficMirrorSession(ctx, session.ID, + driver.TrafficMirrorSessionConfig{TrafficMirrorFilterID: "tmf-missing"}, nil); !cerrors.IsNotFound(err) { + t.Fatalf("expected NotFound modifying session to missing filter, got %v", err) + } + + // A valid modify that doesn't re-point leaves the existing refs intact. + if mod, err := m.ModifyTrafficMirrorSession(ctx, session.ID, + driver.TrafficMirrorSessionConfig{SessionNumber: 7}, nil); err != nil || + mod.SessionNumber != 7 || mod.TrafficMirrorTargetID != target.ID || + mod.TrafficMirrorFilterID != filter.ID { + t.Fatalf("ModifyTrafficMirrorSession valid update: %v %+v", err, mod) + } + + if err := m.DeleteTrafficMirrorSession(ctx, session.ID); err != nil { + t.Fatalf("DeleteTrafficMirrorSession: %v", err) + } + + if err := m.DeleteTrafficMirrorFilterRule(ctx, rule.ID); err != nil { + t.Fatalf("DeleteTrafficMirrorFilterRule: %v", err) + } + + if err := m.DeleteTrafficMirrorFilter(ctx, filter.ID); err != nil { + t.Fatalf("DeleteTrafficMirrorFilter: %v", err) + } + + if err := m.DeleteTrafficMirrorTarget(ctx, target.ID); err != nil { + t.Fatalf("DeleteTrafficMirrorTarget: %v", err) + } + + if err := m.DeleteTrafficMirrorTarget(ctx, target.ID); !cerrors.IsNotFound(err) { + t.Fatalf("expected NotFound on second delete, got %v", err) + } +} + +func TestNetworkInsightsReachability(t *testing.T) { + m := newMock() + ctx := context.Background() + + path, err := m.CreateNetworkInsightsPath(ctx, driver.NetworkInsightsPathConfig{ + Protocol: "tcp", Source: "igw-1", Destination: "eni-1", DestinationPort: 443, + }) + if err != nil || path.ID == "" || path.ARN == "" { + t.Fatalf("CreateNetworkInsightsPath: %v %+v", err, path) + } + + if _, err := m.CreateNetworkInsightsPath(ctx, driver.NetworkInsightsPathConfig{}); !cerrors.IsInvalidArgument(err) { + t.Fatalf("expected InvalidArgument for missing source, got %v", err) + } + + analysis, err := m.StartNetworkInsightsAnalysis(ctx, driver.NetworkInsightsAnalysisConfig{PathID: path.ID}) + if err != nil || analysis.Status != "succeeded" || !analysis.NetworkPathFound { + t.Fatalf("StartNetworkInsightsAnalysis: %v %+v", err, analysis) + } + + got, err := m.DescribeNetworkInsightsAnalyses(ctx, nil, path.ID) + if err != nil || len(got) != 1 { + t.Fatalf("DescribeNetworkInsightsAnalyses: %v %+v", err, got) + } + + // Deleting the path cascades to its analyses. + if err := m.DeleteNetworkInsightsPath(ctx, path.ID); err != nil { + t.Fatalf("DeleteNetworkInsightsPath: %v", err) + } + + if got, _ := m.DescribeNetworkInsightsAnalyses(ctx, nil, ""); len(got) != 0 { + t.Fatalf("expected analyses cascaded on path delete, got %+v", got) + } +} + +func TestNetworkInsightsAccessScope(t *testing.T) { + m := newMock() + ctx := context.Background() + + scope, err := m.CreateNetworkInsightsAccessScope(ctx, driver.NetworkInsightsAccessScopeConfig{ + MatchPaths: []driver.AccessScopePath{{ + Source: &driver.AccessScopeStatement{ + ResourceStatement: &driver.AccessScopeResourceStatement{ResourceTypes: []string{"AWS::EC2::InternetGateway"}}, + }, + }}, + }) + if err != nil || scope.ID == "" || len(scope.MatchPaths) != 1 { + t.Fatalf("CreateNetworkInsightsAccessScope: %v %+v", err, scope) + } + + content, err := m.GetNetworkInsightsAccessScopeContent(ctx, scope.ID) + if err != nil || len(content.MatchPaths) != 1 || + content.MatchPaths[0].Source.ResourceStatement.ResourceTypes[0] != "AWS::EC2::InternetGateway" { + t.Fatalf("GetNetworkInsightsAccessScopeContent: %v %+v", err, content) + } + + analysis, err := m.StartNetworkInsightsAccessScopeAnalysis(ctx, scope.ID, nil) + if err != nil || analysis.Status != "succeeded" { + t.Fatalf("StartNetworkInsightsAccessScopeAnalysis: %v %+v", err, analysis) + } + + findings, status, err := m.GetNetworkInsightsAccessScopeAnalysisFindings(ctx, analysis.ID) + if err != nil || status != "succeeded" || len(findings) != 0 { + t.Fatalf("GetNetworkInsightsAccessScopeAnalysisFindings: %v %q %+v", err, status, findings) + } + + if err := m.DeleteNetworkInsightsAccessScope(ctx, scope.ID); err != nil { + t.Fatalf("DeleteNetworkInsightsAccessScope: %v", err) + } + + if got, _ := m.DescribeNetworkInsightsAccessScopeAnalyses(ctx, nil, ""); len(got) != 0 { + t.Fatalf("expected scope analyses cascaded on delete, got %+v", got) + } +} + +func TestVPCBlockPublicAccess(t *testing.T) { + m := newMock() + ctx := context.Background() + vpcID, subnetID := mustVPC(t, m) + + opts, err := m.DescribeVPCBlockPublicAccessOptions(ctx) + if err != nil || opts.InternetGatewayBlockMode != "off" || opts.State != "default-state" { + t.Fatalf("DescribeVPCBlockPublicAccessOptions default: %v %+v", err, opts) + } + + opts, err = m.ModifyVPCBlockPublicAccessOptions(ctx, "block-bidirectional") + if err != nil || opts.InternetGatewayBlockMode != "block-bidirectional" || opts.State != "update-complete" { + t.Fatalf("ModifyVPCBlockPublicAccessOptions: %v %+v", err, opts) + } + + excl, err := m.CreateVPCBlockPublicAccessExclusion(ctx, driver.VPCBlockPublicAccessExclusionConfig{ + SubnetID: subnetID, InternetGatewayExclusionMode: "allow-bidirectional", + }) + if err != nil || excl.ExclusionID == "" || excl.State != "create-complete" { + t.Fatalf("CreateVPCBlockPublicAccessExclusion: %v %+v", err, excl) + } + + if _, err := m.CreateVPCBlockPublicAccessExclusion(ctx, driver.VPCBlockPublicAccessExclusionConfig{ + VPCID: "vpc-missing", InternetGatewayExclusionMode: "allow-egress", + }); !cerrors.IsNotFound(err) { + t.Fatalf("expected NotFound for missing vpc, got %v", err) + } + + if _, err := m.CreateVPCBlockPublicAccessExclusion(ctx, driver.VPCBlockPublicAccessExclusionConfig{ + VPCID: vpcID, InternetGatewayExclusionMode: "allow-egress", + }); err != nil { + t.Fatalf("CreateVPCBlockPublicAccessExclusion vpc: %v", err) + } + + mod, err := m.ModifyVPCBlockPublicAccessExclusion(ctx, excl.ExclusionID, "allow-egress") + if err != nil || mod.InternetGatewayExclusionMode != "allow-egress" { + t.Fatalf("ModifyVPCBlockPublicAccessExclusion: %v %+v", err, mod) + } + + list, err := m.DescribeVPCBlockPublicAccessExclusions(ctx, nil) + if err != nil || len(list) != 2 { + t.Fatalf("DescribeVPCBlockPublicAccessExclusions: %v %+v", err, list) + } + + del, err := m.DeleteVPCBlockPublicAccessExclusion(ctx, excl.ExclusionID) + if err != nil || del.State != "delete-complete" { + t.Fatalf("DeleteVPCBlockPublicAccessExclusion: %v %+v", err, del) + } + + if _, err := m.DeleteVPCBlockPublicAccessExclusion(ctx, excl.ExclusionID); !cerrors.IsNotFound(err) { + t.Fatalf("expected NotFound on second delete, got %v", err) + } +} diff --git a/providers/aws/vpc/networkinsights.go b/providers/aws/vpc/networkinsights.go new file mode 100644 index 00000000..18bc688f --- /dev/null +++ b/providers/aws/vpc/networkinsights.go @@ -0,0 +1,351 @@ +package vpc + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +// ---- Reachability Analyzer: paths ---- + +// CreateNetworkInsightsPath creates a reachability path definition. +// +//nolint:gocritic // cfg is passed by value to satisfy the driver interface. +func (m *Mock) CreateNetworkInsightsPath( + _ context.Context, cfg driver.NetworkInsightsPathConfig, +) (*driver.NetworkInsightsPath, error) { + if cfg.Source == "" { + return nil, errors.Newf(errors.InvalidArgument, "source is required") + } + + if cfg.Destination == "" { + return nil, errors.Newf(errors.InvalidArgument, "destination is required") + } + + id := idgen.GenerateID("nip-") + p := &driver.NetworkInsightsPath{ + ID: id, + ARN: m.insightsARN("network-insights-path", id), + Protocol: orDefaultStr(cfg.Protocol, "tcp"), + Source: cfg.Source, + SourceIP: cfg.SourceIP, + Destination: cfg.Destination, + DestinationIP: cfg.DestinationIP, + DestinationPort: cfg.DestinationPort, + CreatedDate: m.opts.Clock.Now().UTC(), + Tags: copyTags(cfg.Tags), + } + m.networkInsightsPaths.Set(id, p) + + out := cloneInsightsPath(p) + + return &out, nil +} + +// DeleteNetworkInsightsPath deletes a path and its analyses. +func (m *Mock) DeleteNetworkInsightsPath(_ context.Context, id string) error { + if !m.networkInsightsPaths.Delete(id) { + return errors.Newf(errors.NotFound, "network insights path %q not found", id) + } + + for aID, a := range m.networkInsightsAnalyses.All() { + if a.PathID == id { + m.networkInsightsAnalyses.Delete(aID) + } + } + + return nil +} + +// DescribeNetworkInsightsPaths returns paths matching ids. +func (m *Mock) DescribeNetworkInsightsPaths(_ context.Context, ids []string) ([]driver.NetworkInsightsPath, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.networkInsightsPaths, ids, cloneInsightsPath), nil +} + +// ---- Reachability Analyzer: analyses ---- + +// StartNetworkInsightsAnalysis runs reachability analysis on a path. The mock +// completes synchronously with a reachable result. +// +//nolint:gocritic // cfg is passed by value to satisfy the driver interface. +func (m *Mock) StartNetworkInsightsAnalysis( + _ context.Context, cfg driver.NetworkInsightsAnalysisConfig, +) (*driver.NetworkInsightsAnalysis, error) { + if !m.networkInsightsPaths.Has(cfg.PathID) { + return nil, errors.Newf(errors.NotFound, "network insights path %q not found", cfg.PathID) + } + + id := idgen.GenerateID("nia-") + a := &driver.NetworkInsightsAnalysis{ + ID: id, + ARN: m.insightsARN("network-insights-analysis", id), + PathID: cfg.PathID, + StartDate: m.opts.Clock.Now().UTC(), + Status: "succeeded", + NetworkPathFound: true, + FilterInARNs: append([]string(nil), cfg.FilterInARNs...), + FilterOutARNs: append([]string(nil), cfg.FilterOutARNs...), + AdditionalAccounts: append([]string(nil), cfg.AdditionalAccounts...), + Tags: copyTags(cfg.Tags), + } + m.networkInsightsAnalyses.Set(id, a) + + out := cloneInsightsAnalysis(a) + + return &out, nil +} + +// DeleteNetworkInsightsAnalysis deletes an analysis. +func (m *Mock) DeleteNetworkInsightsAnalysis(_ context.Context, id string) error { + if !m.networkInsightsAnalyses.Delete(id) { + return errors.Newf(errors.NotFound, "network insights analysis %q not found", id) + } + + return nil +} + +// DescribeNetworkInsightsAnalyses returns analyses matching ids, optionally +// scoped to a path. +func (m *Mock) DescribeNetworkInsightsAnalyses( + _ context.Context, ids []string, pathID string, +) ([]driver.NetworkInsightsAnalysis, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + all := describeResources(m.networkInsightsAnalyses, ids, cloneInsightsAnalysis) + if pathID == "" { + return all, nil + } + + out := make([]driver.NetworkInsightsAnalysis, 0, len(all)) + + for i := range all { + if all[i].PathID == pathID { + out = append(out, all[i]) + } + } + + return out, nil +} + +// ---- Network Access Analyzer: access scopes ---- + +// CreateNetworkInsightsAccessScope creates an access-scope definition. +func (m *Mock) CreateNetworkInsightsAccessScope( + _ context.Context, cfg driver.NetworkInsightsAccessScopeConfig, +) (*driver.NetworkInsightsAccessScope, error) { + now := m.opts.Clock.Now().UTC() + id := idgen.GenerateID("nis-") + s := &driver.NetworkInsightsAccessScope{ + ID: id, + ARN: m.insightsARN("network-insights-access-scope", id), + MatchPaths: cloneAccessScopePaths(cfg.MatchPaths), + ExcludePaths: cloneAccessScopePaths(cfg.ExcludePaths), + CreatedDate: now, + UpdatedDate: now, + Tags: copyTags(cfg.Tags), + } + m.networkInsightsAccessScopes.Set(id, s) + + out := cloneAccessScope(s) + + return &out, nil +} + +// DeleteNetworkInsightsAccessScope deletes a scope and its analyses. +func (m *Mock) DeleteNetworkInsightsAccessScope(_ context.Context, id string) error { + if !m.networkInsightsAccessScopes.Delete(id) { + return errors.Newf(errors.NotFound, "network insights access scope %q not found", id) + } + + for aID, a := range m.networkInsightsAccessScopeAnalyses.All() { + if a.AccessScopeID == id { + m.networkInsightsAccessScopeAnalyses.Delete(aID) + } + } + + return nil +} + +// DescribeNetworkInsightsAccessScopes returns scopes matching ids. +func (m *Mock) DescribeNetworkInsightsAccessScopes( + _ context.Context, ids []string, +) ([]driver.NetworkInsightsAccessScope, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.networkInsightsAccessScopes, ids, cloneAccessScope), nil +} + +// GetNetworkInsightsAccessScopeContent returns the scope with its match/exclude +// paths. +func (m *Mock) GetNetworkInsightsAccessScopeContent( + _ context.Context, id string, +) (*driver.NetworkInsightsAccessScope, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + s, ok := m.networkInsightsAccessScopes.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "network insights access scope %q not found", id) + } + + out := cloneAccessScope(s) + + return &out, nil +} + +// ---- Network Access Analyzer: scope analyses ---- + +// StartNetworkInsightsAccessScopeAnalysis runs analysis on an access scope. The +// mock completes synchronously with no findings. +func (m *Mock) StartNetworkInsightsAccessScopeAnalysis( + _ context.Context, accessScopeID string, tags map[string]string, +) (*driver.NetworkInsightsAccessScopeAnalysis, error) { + if !m.networkInsightsAccessScopes.Has(accessScopeID) { + return nil, errors.Newf(errors.NotFound, + "network insights access scope %q not found", accessScopeID) + } + + now := m.opts.Clock.Now().UTC() + id := idgen.GenerateID("nisa-") + a := &driver.NetworkInsightsAccessScopeAnalysis{ + ID: id, + ARN: m.insightsARN("network-insights-access-scope-analysis", id), + AccessScopeID: accessScopeID, + Status: "succeeded", + StartDate: now, + EndDate: now, + FindingsFound: "false", + AnalyzedEniCount: 0, + Tags: copyTags(tags), + } + m.networkInsightsAccessScopeAnalyses.Set(id, a) + + out := cloneAccessScopeAnalysis(a) + + return &out, nil +} + +// DeleteNetworkInsightsAccessScopeAnalysis deletes a scope analysis. +func (m *Mock) DeleteNetworkInsightsAccessScopeAnalysis(_ context.Context, id string) error { + if !m.networkInsightsAccessScopeAnalyses.Delete(id) { + return errors.Newf(errors.NotFound, "network insights access scope analysis %q not found", id) + } + + return nil +} + +// DescribeNetworkInsightsAccessScopeAnalyses returns scope analyses matching +// ids, optionally scoped to an access scope. +func (m *Mock) DescribeNetworkInsightsAccessScopeAnalyses( + _ context.Context, ids []string, accessScopeID string, +) ([]driver.NetworkInsightsAccessScopeAnalysis, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + all := describeResources(m.networkInsightsAccessScopeAnalyses, ids, cloneAccessScopeAnalysis) + if accessScopeID == "" { + return all, nil + } + + out := make([]driver.NetworkInsightsAccessScopeAnalysis, 0, len(all)) + + for i := range all { + if all[i].AccessScopeID == accessScopeID { + out = append(out, all[i]) + } + } + + return out, nil +} + +// GetNetworkInsightsAccessScopeAnalysisFindings returns findings for an analysis +// and its status. The mock produces no findings. +func (m *Mock) GetNetworkInsightsAccessScopeAnalysisFindings( + _ context.Context, analysisID string, +) ([]driver.AccessScopeAnalysisFinding, string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + a, ok := m.networkInsightsAccessScopeAnalyses.Get(analysisID) + if !ok { + return nil, "", errors.Newf(errors.NotFound, + "network insights access scope analysis %q not found", analysisID) + } + + return []driver.AccessScopeAnalysisFinding{}, a.Status, nil +} + +// ---- helpers ---- + +func (m *Mock) insightsARN(resource, id string) string { + return "arn:aws:ec2:" + m.opts.Region + ":" + m.opts.AccountID + ":" + resource + "/" + id +} + +func cloneInsightsPath(p *driver.NetworkInsightsPath) driver.NetworkInsightsPath { + out := *p + out.Tags = copyTags(p.Tags) + + return out +} + +func cloneInsightsAnalysis(a *driver.NetworkInsightsAnalysis) driver.NetworkInsightsAnalysis { + out := *a + out.Tags = copyTags(a.Tags) + out.FilterInARNs = append([]string(nil), a.FilterInARNs...) + out.FilterOutARNs = append([]string(nil), a.FilterOutARNs...) + out.AdditionalAccounts = append([]string(nil), a.AdditionalAccounts...) + + return out +} + +func cloneAccessScope(s *driver.NetworkInsightsAccessScope) driver.NetworkInsightsAccessScope { + out := *s + out.Tags = copyTags(s.Tags) + out.MatchPaths = cloneAccessScopePaths(s.MatchPaths) + out.ExcludePaths = cloneAccessScopePaths(s.ExcludePaths) + + return out +} + +func cloneAccessScopeAnalysis(a *driver.NetworkInsightsAccessScopeAnalysis) driver.NetworkInsightsAccessScopeAnalysis { + out := *a + out.Tags = copyTags(a.Tags) + + return out +} + +func cloneAccessScopePaths(paths []driver.AccessScopePath) []driver.AccessScopePath { + if len(paths) == 0 { + return nil + } + + out := make([]driver.AccessScopePath, 0, len(paths)) + for i := range paths { + out = append(out, driver.AccessScopePath{ + Source: cloneAccessScopeStatement(paths[i].Source), + Destination: cloneAccessScopeStatement(paths[i].Destination), + }) + } + + return out +} + +func cloneAccessScopeStatement(s *driver.AccessScopeStatement) *driver.AccessScopeStatement { + if s == nil || s.ResourceStatement == nil { + return nil + } + + return &driver.AccessScopeStatement{ + ResourceStatement: &driver.AccessScopeResourceStatement{ + ResourceTypes: append([]string(nil), s.ResourceStatement.ResourceTypes...), + Resources: append([]string(nil), s.ResourceStatement.Resources...), + }, + } +} diff --git a/providers/aws/vpc/prefixlist.go b/providers/aws/vpc/prefixlist.go new file mode 100644 index 00000000..1e4ee734 --- /dev/null +++ b/providers/aws/vpc/prefixlist.go @@ -0,0 +1,139 @@ +package vpc + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +// CreateManagedPrefixList creates a customer-managed prefix list. +func (m *Mock) CreateManagedPrefixList(_ context.Context, cfg driver.PrefixListConfig) (*driver.PrefixList, error) { + if cfg.Name == "" { + return nil, errors.New(errors.InvalidArgument, "prefix list name is required") + } + + if cfg.MaxEntries <= 0 { + return nil, errors.New(errors.InvalidArgument, "maxEntries must be greater than zero") + } + + if len(cfg.Entries) > cfg.MaxEntries { + return nil, errors.Newf(errors.InvalidArgument, + "prefix list has %d entries, exceeding maxEntries %d", len(cfg.Entries), cfg.MaxEntries) + } + + pl := &driver.PrefixList{ + ID: idgen.GenerateID("pl-"), + Name: cfg.Name, + AddressFamily: orDefaultStr(cfg.AddressFamily, "IPv4"), + MaxEntries: cfg.MaxEntries, + State: "create-complete", + Version: 1, + Entries: cloneEntries(cfg.Entries), + Tags: copyTags(cfg.Tags), + } + m.prefixLists.Set(pl.ID, pl) + + out := clonePrefixList(pl) + + return &out, nil +} + +// DeleteManagedPrefixList deletes a managed prefix list. +func (m *Mock) DeleteManagedPrefixList(_ context.Context, id string) (*driver.PrefixList, error) { + m.mu.Lock() + defer m.mu.Unlock() + + pl, ok := m.prefixLists.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "prefix list %q not found", id) + } + + pl.State = "delete-complete" + + m.prefixLists.Delete(id) + + out := clonePrefixList(pl) + + return &out, nil +} + +// DescribeManagedPrefixLists returns prefix lists matching ids. +func (m *Mock) DescribeManagedPrefixLists(_ context.Context, ids []string) ([]driver.PrefixList, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.prefixLists, ids, clonePrefixList), nil +} + +// GetManagedPrefixListEntries returns the entries of a prefix list. +func (m *Mock) GetManagedPrefixListEntries(_ context.Context, id string) ([]driver.PrefixListEntry, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + pl, ok := m.prefixLists.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "prefix list %q not found", id) + } + + return cloneEntries(pl.Entries), nil +} + +func cloneEntries(in []driver.PrefixListEntry) []driver.PrefixListEntry { + if len(in) == 0 { + return nil + } + + return append([]driver.PrefixListEntry(nil), in...) +} + +func clonePrefixList(p *driver.PrefixList) driver.PrefixList { + out := *p + out.Entries = cloneEntries(p.Entries) + out.Tags = copyTags(p.Tags) + + return out +} + +// ModifyManagedPrefixList adds and/or removes entries, bumping the version. +// +//nolint:gocritic // slices match the driver signature. +func (m *Mock) ModifyManagedPrefixList( + _ context.Context, id string, addEntries []driver.PrefixListEntry, removeCIDRs []string, +) (*driver.PrefixList, error) { + m.mu.Lock() + defer m.mu.Unlock() + + pl, ok := m.prefixLists.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "prefix list %q not found", id) + } + + remove := make(map[string]bool, len(removeCIDRs)) + for _, c := range removeCIDRs { + remove[c] = true + } + + kept := pl.Entries[:0:0] + + for _, e := range pl.Entries { + if !remove[e.CIDR] { + kept = append(kept, e) + } + } + + updated := append(kept, addEntries...) + if len(updated) > pl.MaxEntries { + return nil, errors.Newf(errors.InvalidArgument, + "prefix list would have %d entries, exceeding maxEntries %d", len(updated), pl.MaxEntries) + } + + pl.Entries = updated + pl.Version++ + m.prefixLists.Set(id, pl) + + out := clonePrefixList(pl) + + return &out, nil +} diff --git a/providers/aws/vpc/trafficmirror.go b/providers/aws/vpc/trafficmirror.go new file mode 100644 index 00000000..9568dabc --- /dev/null +++ b/providers/aws/vpc/trafficmirror.go @@ -0,0 +1,608 @@ +package vpc + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +// defaultVirtualNetworkID is the VNI EC2 auto-assigns to a mirror session when +// the caller omits one. +const defaultVirtualNetworkID = 1 + +// Removable field names accepted by the Modify* RemoveFields parameter. +const ( + fieldDescription = "description" + fieldProtocol = "protocol" + fieldDestinationPortRange = "destination-port-range" + fieldSourcePortRange = "source-port-range" + fieldPacketLength = "packet-length" + fieldVirtualNetworkID = "virtual-network-id" +) + +// ---- Traffic Mirror Targets ---- + +// CreateTrafficMirrorTarget creates a mirror target from an ENI, NLB, or GWLB +// endpoint. +func (m *Mock) CreateTrafficMirrorTarget( + _ context.Context, cfg driver.TrafficMirrorTargetConfig, +) (*driver.TrafficMirrorTarget, error) { + t := &driver.TrafficMirrorTarget{ + ID: idgen.GenerateID("tmt-"), + Description: cfg.Description, + NetworkInterfaceID: cfg.NetworkInterfaceID, + NetworkLoadBalancerARN: cfg.NetworkLoadBalancerARN, + GatewayLoadBalancerEndpointID: cfg.GatewayLoadBalancerEndpointID, + Type: trafficMirrorTargetType(cfg), + OwnerID: m.opts.AccountID, + Tags: copyTags(cfg.Tags), + } + m.trafficMirrorTargets.Set(t.ID, t) + + out := cloneTrafficMirrorTarget(t) + + return &out, nil +} + +// trafficMirrorTargetType derives the target type from whichever destination +// the caller supplied, matching how real EC2 infers it. +func trafficMirrorTargetType(cfg driver.TrafficMirrorTargetConfig) string { + switch { + case cfg.NetworkLoadBalancerARN != "": + return "network-load-balancer" + case cfg.GatewayLoadBalancerEndpointID != "": + return "gateway-load-balancer-endpoint" + default: + return "network-interface" + } +} + +// DeleteTrafficMirrorTarget deletes a mirror target. Real EC2 refuses the +// delete while a session still references the target. +func (m *Mock) DeleteTrafficMirrorTarget(_ context.Context, id string) error { + if !m.trafficMirrorTargets.Has(id) { + return errors.Newf(errors.NotFound, "traffic mirror target %q not found", id) + } + + if sid, inUse := m.sessionReferencingTarget(id); inUse { + return errors.Newf(errors.FailedPrecondition, + "DependencyViolation: traffic mirror session %q still references target %q", sid, id) + } + + m.trafficMirrorTargets.Delete(id) + + return nil +} + +// sessionReferencingTarget reports a session still bound to the given target. +func (m *Mock) sessionReferencingTarget(targetID string) (string, bool) { + for _, s := range m.trafficMirrorSessions.All() { + if s.TrafficMirrorTargetID == targetID { + return s.ID, true + } + } + + return "", false +} + +// sessionReferencingFilter reports a session still bound to the given filter. +func (m *Mock) sessionReferencingFilter(filterID string) (string, bool) { + for _, s := range m.trafficMirrorSessions.All() { + if s.TrafficMirrorFilterID == filterID { + return s.ID, true + } + } + + return "", false +} + +// DescribeTrafficMirrorTargets returns mirror targets matching ids. +func (m *Mock) DescribeTrafficMirrorTargets(_ context.Context, ids []string) ([]driver.TrafficMirrorTarget, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.trafficMirrorTargets, ids, cloneTrafficMirrorTarget), nil +} + +// ---- Traffic Mirror Filters ---- + +// CreateTrafficMirrorFilter creates an empty mirror filter. +func (m *Mock) CreateTrafficMirrorFilter( + _ context.Context, description string, tags map[string]string, +) (*driver.TrafficMirrorFilter, error) { + f := &driver.TrafficMirrorFilter{ + ID: idgen.GenerateID("tmf-"), + Description: description, + NetworkServices: []string{}, + IngressRules: []driver.TrafficMirrorFilterRule{}, + EgressRules: []driver.TrafficMirrorFilterRule{}, + Tags: copyTags(tags), + } + m.trafficMirrorFilters.Set(f.ID, f) + + out := cloneTrafficMirrorFilter(f) + + return &out, nil +} + +// DeleteTrafficMirrorFilter deletes a mirror filter. Real EC2 refuses the +// delete while a session still references the filter. +func (m *Mock) DeleteTrafficMirrorFilter(_ context.Context, id string) error { + if !m.trafficMirrorFilters.Has(id) { + return errors.Newf(errors.NotFound, "traffic mirror filter %q not found", id) + } + + if sid, inUse := m.sessionReferencingFilter(id); inUse { + return errors.Newf(errors.FailedPrecondition, + "DependencyViolation: traffic mirror session %q still references filter %q", sid, id) + } + + m.trafficMirrorFilters.Delete(id) + + return nil +} + +// DescribeTrafficMirrorFilters returns mirror filters matching ids. +func (m *Mock) DescribeTrafficMirrorFilters(_ context.Context, ids []string) ([]driver.TrafficMirrorFilter, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.trafficMirrorFilters, ids, cloneTrafficMirrorFilter), nil +} + +// ModifyTrafficMirrorFilterNetworkServices adds/removes monitored network +// services on a filter. +func (m *Mock) ModifyTrafficMirrorFilterNetworkServices( + _ context.Context, filterID string, add, remove []string, +) (*driver.TrafficMirrorFilter, error) { + m.mu.Lock() + defer m.mu.Unlock() + + f, ok := m.trafficMirrorFilters.Get(filterID) + if !ok { + return nil, errors.Newf(errors.NotFound, "traffic mirror filter %q not found", filterID) + } + + f.NetworkServices = applyStringSetChanges(f.NetworkServices, add, remove) + + out := cloneTrafficMirrorFilter(f) + + return &out, nil +} + +// ---- Traffic Mirror Filter Rules ---- + +// CreateTrafficMirrorFilterRule adds a rule to a filter's ingress or egress set. +// +//nolint:gocritic // cfg is passed by value to satisfy the driver interface. +func (m *Mock) CreateTrafficMirrorFilterRule( + _ context.Context, cfg driver.TrafficMirrorFilterRuleConfig, +) (*driver.TrafficMirrorFilterRule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + f, ok := m.trafficMirrorFilters.Get(cfg.FilterID) + if !ok { + return nil, errors.Newf(errors.NotFound, "traffic mirror filter %q not found", cfg.FilterID) + } + + rule := driver.TrafficMirrorFilterRule{ + ID: idgen.GenerateID("tmfr-"), + FilterID: cfg.FilterID, + TrafficDirection: cfg.TrafficDirection, + RuleNumber: cfg.RuleNumber, + RuleAction: cfg.RuleAction, + Protocol: cfg.Protocol, + DestinationCIDR: cfg.DestinationCIDR, + SourceCIDR: cfg.SourceCIDR, + DestinationPortRange: clonePortRange(cfg.DestinationPortRange), + SourcePortRange: clonePortRange(cfg.SourcePortRange), + Description: cfg.Description, + } + + if cfg.TrafficDirection == "egress" { + f.EgressRules = append(f.EgressRules, rule) + } else { + f.IngressRules = append(f.IngressRules, rule) + } + + out := cloneFilterRule(&rule) + + return &out, nil +} + +// ModifyTrafficMirrorFilterRule updates an existing rule. Fields listed in +// removeFields are cleared; other provided fields overwrite. +// +//nolint:gocritic // cfg is passed by value to satisfy the driver interface. +func (m *Mock) ModifyTrafficMirrorFilterRule( + _ context.Context, id string, cfg driver.TrafficMirrorFilterRuleConfig, removeFields []string, +) (*driver.TrafficMirrorFilterRule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + f, rule := m.findFilterRule(id) + if rule == nil { + return nil, errors.Newf(errors.NotFound, "traffic mirror filter rule %q not found", id) + } + + applyFilterRuleUpdate(rule, &cfg) + applyFilterRuleRemovals(rule, removeFields) + + m.trafficMirrorFilters.Set(f.ID, f) + + out := cloneFilterRule(rule) + + return &out, nil +} + +// findFilterRule returns the owning filter and a pointer to the rule with id, +// or (nil, nil) if not found. Caller holds mu. +func (m *Mock) findFilterRule(id string) (*driver.TrafficMirrorFilter, *driver.TrafficMirrorFilterRule) { + for _, f := range m.trafficMirrorFilters.All() { + for i := range f.IngressRules { + if f.IngressRules[i].ID == id { + return f, &f.IngressRules[i] + } + } + + for i := range f.EgressRules { + if f.EgressRules[i].ID == id { + return f, &f.EgressRules[i] + } + } + } + + return nil, nil +} + +func applyFilterRuleUpdate(rule *driver.TrafficMirrorFilterRule, cfg *driver.TrafficMirrorFilterRuleConfig) { + if cfg.TrafficDirection != "" { + rule.TrafficDirection = cfg.TrafficDirection + } + + if cfg.RuleNumber != 0 { + rule.RuleNumber = cfg.RuleNumber + } + + if cfg.RuleAction != "" { + rule.RuleAction = cfg.RuleAction + } + + if cfg.Protocol != 0 { + rule.Protocol = cfg.Protocol + } + + if cfg.DestinationCIDR != "" { + rule.DestinationCIDR = cfg.DestinationCIDR + } + + if cfg.SourceCIDR != "" { + rule.SourceCIDR = cfg.SourceCIDR + } + + if cfg.DestinationPortRange != nil { + rule.DestinationPortRange = clonePortRange(cfg.DestinationPortRange) + } + + if cfg.SourcePortRange != nil { + rule.SourcePortRange = clonePortRange(cfg.SourcePortRange) + } + + if cfg.Description != "" { + rule.Description = cfg.Description + } +} + +func applyFilterRuleRemovals(rule *driver.TrafficMirrorFilterRule, removeFields []string) { + for _, field := range removeFields { + switch field { + case fieldDestinationPortRange: + rule.DestinationPortRange = nil + case fieldSourcePortRange: + rule.SourcePortRange = nil + case fieldProtocol: + rule.Protocol = 0 + case fieldDescription: + rule.Description = "" + } + } +} + +// DeleteTrafficMirrorFilterRule removes a rule from its owning filter. +func (m *Mock) DeleteTrafficMirrorFilterRule(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + for _, f := range m.trafficMirrorFilters.All() { + if removeRuleByID(&f.IngressRules, id) || removeRuleByID(&f.EgressRules, id) { + m.trafficMirrorFilters.Set(f.ID, f) + return nil + } + } + + return errors.Newf(errors.NotFound, "traffic mirror filter rule %q not found", id) +} + +func removeRuleByID(rules *[]driver.TrafficMirrorFilterRule, id string) bool { + for i := range *rules { + if (*rules)[i].ID == id { + *rules = append((*rules)[:i], (*rules)[i+1:]...) + return true + } + } + + return false +} + +// DescribeTrafficMirrorFilterRules returns rules for a filter (and optionally a +// specific rule-id subset). +func (m *Mock) DescribeTrafficMirrorFilterRules( + _ context.Context, filterID string, ruleIDs []string, +) ([]driver.TrafficMirrorFilterRule, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var rules []driver.TrafficMirrorFilterRule + + for _, f := range m.trafficMirrorFilters.All() { + if filterID != "" && f.ID != filterID { + continue + } + + rules = append(rules, f.IngressRules...) + rules = append(rules, f.EgressRules...) + } + + if len(ruleIDs) > 0 { + rules = filterRulesByID(rules, ruleIDs) + } + + out := make([]driver.TrafficMirrorFilterRule, 0, len(rules)) + for i := range rules { + out = append(out, cloneFilterRule(&rules[i])) + } + + return out, nil +} + +func filterRulesByID(rules []driver.TrafficMirrorFilterRule, ids []string) []driver.TrafficMirrorFilterRule { + want := make(map[string]struct{}, len(ids)) + for _, id := range ids { + want[id] = struct{}{} + } + + out := rules[:0] + + for i := range rules { + if _, ok := want[rules[i].ID]; ok { + out = append(out, rules[i]) + } + } + + return out +} + +// ---- Traffic Mirror Sessions ---- + +// CreateTrafficMirrorSession binds a source ENI to a target and filter. +// +//nolint:gocritic // cfg is passed by value to satisfy the driver interface. +func (m *Mock) CreateTrafficMirrorSession( + _ context.Context, cfg driver.TrafficMirrorSessionConfig, +) (*driver.TrafficMirrorSession, error) { + if !m.trafficMirrorTargets.Has(cfg.TrafficMirrorTargetID) { + return nil, errors.Newf(errors.NotFound, + "traffic mirror target %q not found", cfg.TrafficMirrorTargetID) + } + + if !m.trafficMirrorFilters.Has(cfg.TrafficMirrorFilterID) { + return nil, errors.Newf(errors.NotFound, + "traffic mirror filter %q not found", cfg.TrafficMirrorFilterID) + } + + vni := cfg.VirtualNetworkID + if vni == 0 { + vni = defaultVirtualNetworkID + } + + s := &driver.TrafficMirrorSession{ + ID: idgen.GenerateID("tms-"), + NetworkInterfaceID: cfg.NetworkInterfaceID, + TrafficMirrorTargetID: cfg.TrafficMirrorTargetID, + TrafficMirrorFilterID: cfg.TrafficMirrorFilterID, + PacketLength: cfg.PacketLength, + SessionNumber: cfg.SessionNumber, + VirtualNetworkID: vni, + Description: cfg.Description, + OwnerID: m.opts.AccountID, + Tags: copyTags(cfg.Tags), + } + m.trafficMirrorSessions.Set(s.ID, s) + + out := cloneTrafficMirrorSession(s) + + return &out, nil +} + +// ModifyTrafficMirrorSession updates a session. Fields in removeFields are +// cleared; other provided fields overwrite. +// +//nolint:gocritic // cfg is passed by value to satisfy the driver interface. +func (m *Mock) ModifyTrafficMirrorSession( + _ context.Context, id string, cfg driver.TrafficMirrorSessionConfig, removeFields []string, +) (*driver.TrafficMirrorSession, error) { + m.mu.Lock() + defer m.mu.Unlock() + + s, ok := m.trafficMirrorSessions.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "traffic mirror session %q not found", id) + } + + // Re-validate a re-pointed target/filter, matching Create — otherwise a + // Modify could bind the session to a nonexistent target or filter. + if cfg.TrafficMirrorTargetID != "" && !m.trafficMirrorTargets.Has(cfg.TrafficMirrorTargetID) { + return nil, errors.Newf(errors.NotFound, + "traffic mirror target %q not found", cfg.TrafficMirrorTargetID) + } + + if cfg.TrafficMirrorFilterID != "" && !m.trafficMirrorFilters.Has(cfg.TrafficMirrorFilterID) { + return nil, errors.Newf(errors.NotFound, + "traffic mirror filter %q not found", cfg.TrafficMirrorFilterID) + } + + applySessionUpdate(s, &cfg) + applySessionRemovals(s, removeFields) + + out := cloneTrafficMirrorSession(s) + + return &out, nil +} + +func applySessionUpdate(s *driver.TrafficMirrorSession, cfg *driver.TrafficMirrorSessionConfig) { + if cfg.TrafficMirrorTargetID != "" { + s.TrafficMirrorTargetID = cfg.TrafficMirrorTargetID + } + + if cfg.TrafficMirrorFilterID != "" { + s.TrafficMirrorFilterID = cfg.TrafficMirrorFilterID + } + + if cfg.PacketLength != 0 { + s.PacketLength = cfg.PacketLength + } + + if cfg.SessionNumber != 0 { + s.SessionNumber = cfg.SessionNumber + } + + if cfg.VirtualNetworkID != 0 { + s.VirtualNetworkID = cfg.VirtualNetworkID + } + + if cfg.Description != "" { + s.Description = cfg.Description + } +} + +func applySessionRemovals(s *driver.TrafficMirrorSession, removeFields []string) { + for _, field := range removeFields { + switch field { + case fieldPacketLength: + s.PacketLength = 0 + case fieldDescription: + s.Description = "" + case fieldVirtualNetworkID: + s.VirtualNetworkID = 0 + } + } +} + +// DeleteTrafficMirrorSession deletes a mirror session. +func (m *Mock) DeleteTrafficMirrorSession(_ context.Context, id string) error { + if !m.trafficMirrorSessions.Delete(id) { + return errors.Newf(errors.NotFound, "traffic mirror session %q not found", id) + } + + return nil +} + +// DescribeTrafficMirrorSessions returns sessions matching ids. +func (m *Mock) DescribeTrafficMirrorSessions(_ context.Context, ids []string) ([]driver.TrafficMirrorSession, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.trafficMirrorSessions, ids, cloneTrafficMirrorSession), nil +} + +// ---- clone helpers ---- + +func cloneTrafficMirrorTarget(t *driver.TrafficMirrorTarget) driver.TrafficMirrorTarget { + out := *t + out.Tags = copyTags(t.Tags) + + return out +} + +func cloneTrafficMirrorFilter(f *driver.TrafficMirrorFilter) driver.TrafficMirrorFilter { + out := *f + out.Tags = copyTags(f.Tags) + out.NetworkServices = append([]string(nil), f.NetworkServices...) + out.IngressRules = cloneFilterRules(f.IngressRules) + out.EgressRules = cloneFilterRules(f.EgressRules) + + return out +} + +func cloneFilterRules(rules []driver.TrafficMirrorFilterRule) []driver.TrafficMirrorFilterRule { + if len(rules) == 0 { + return nil + } + + out := make([]driver.TrafficMirrorFilterRule, 0, len(rules)) + for i := range rules { + out = append(out, cloneFilterRule(&rules[i])) + } + + return out +} + +func cloneFilterRule(r *driver.TrafficMirrorFilterRule) driver.TrafficMirrorFilterRule { + out := *r + out.DestinationPortRange = clonePortRange(r.DestinationPortRange) + out.SourcePortRange = clonePortRange(r.SourcePortRange) + + return out +} + +func clonePortRange(p *driver.TrafficMirrorPortRange) *driver.TrafficMirrorPortRange { + if p == nil { + return nil + } + + cp := *p + + return &cp +} + +func cloneTrafficMirrorSession(s *driver.TrafficMirrorSession) driver.TrafficMirrorSession { + out := *s + out.Tags = copyTags(s.Tags) + + return out +} + +// applyStringSetChanges returns base with add appended (deduped) and remove +// deleted, preserving order. +func applyStringSetChanges(base, add, remove []string) []string { + drop := make(map[string]struct{}, len(remove)) + for _, r := range remove { + drop[r] = struct{}{} + } + + seen := make(map[string]struct{}, len(base)+len(add)) + out := make([]string, 0, len(base)+len(add)) + + combined := make([]string, 0, len(base)+len(add)) + combined = append(combined, base...) + combined = append(combined, add...) + + for _, v := range combined { + if _, gone := drop[v]; gone { + continue + } + + if _, dup := seen[v]; dup { + continue + } + + seen[v] = struct{}{} + + out = append(out, v) + } + + return out +} diff --git a/providers/aws/vpc/transitgateway.go b/providers/aws/vpc/transitgateway.go new file mode 100644 index 00000000..11758117 --- /dev/null +++ b/providers/aws/vpc/transitgateway.go @@ -0,0 +1,329 @@ +package vpc + +import ( + "context" + "strings" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +const defaultAmazonSideASN = 64512 + +// CreateTransitGateway creates a transit gateway. +func (m *Mock) CreateTransitGateway(_ context.Context, cfg driver.TransitGatewayConfig) (*driver.TransitGateway, error) { + asn := cfg.ASN + if asn == 0 { + asn = defaultAmazonSideASN + } + + tgw := &driver.TransitGateway{ + ID: idgen.GenerateID("tgw-"), + State: "available", + ASN: asn, + Description: cfg.Description, + OwnerID: m.opts.AccountID, + Tags: copyTags(cfg.Tags), + } + m.transitGateways.Set(tgw.ID, tgw) + + out := cloneTGW(tgw) + + return &out, nil +} + +// DeleteTransitGateway deletes a transit gateway. +func (m *Mock) DeleteTransitGateway(_ context.Context, id string) (*driver.TransitGateway, error) { + m.mu.Lock() + defer m.mu.Unlock() + + tgw, ok := m.transitGateways.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "transit gateway %q not found", id) + } + + if m.transitGatewayInUse(id) { + return nil, errors.Newf(errors.FailedPrecondition, "transit gateway %q has attachments or route tables", id) + } + + tgw.State = NATStateDeleted + + m.transitGateways.Delete(id) + + out := cloneTGW(tgw) + + return &out, nil +} + +// DescribeTransitGateways returns transit gateways matching ids (all if empty). +func (m *Mock) DescribeTransitGateways(_ context.Context, ids []string) ([]driver.TransitGateway, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.transitGateways, ids, cloneTGW), nil +} + +// CreateTransitGatewayVPCAttachment attaches a VPC to a transit gateway. +func (m *Mock) CreateTransitGatewayVPCAttachment( + _ context.Context, cfg driver.TransitGatewayVPCAttachmentConfig, +) (*driver.TransitGatewayVPCAttachment, error) { + if !m.transitGateways.Has(cfg.TransitGatewayID) { + return nil, errors.Newf(errors.InvalidArgument, "transit gateway %q not found", cfg.TransitGatewayID) + } + + if !m.vpcs.Has(cfg.VPCID) { + return nil, errors.Newf(errors.InvalidArgument, "vpc %q not found", cfg.VPCID) + } + + att := &driver.TransitGatewayVPCAttachment{ + ID: idgen.GenerateID("tgw-attach-"), + TransitGatewayID: cfg.TransitGatewayID, + VPCID: cfg.VPCID, + SubnetIDs: append([]string(nil), cfg.SubnetIDs...), + State: "available", + Tags: copyTags(cfg.Tags), + } + m.tgwAttachments.Set(att.ID, att) + + out := cloneTGWAttachment(att) + + return &out, nil +} + +// DeleteTransitGatewayVPCAttachment deletes a transit gateway VPC attachment. +func (m *Mock) DeleteTransitGatewayVPCAttachment(_ context.Context, id string) (*driver.TransitGatewayVPCAttachment, error) { + m.mu.Lock() + defer m.mu.Unlock() + + att, ok := m.tgwAttachments.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "transit gateway attachment %q not found", id) + } + + att.State = NATStateDeleted + + m.tgwAttachments.Delete(id) + + out := cloneTGWAttachment(att) + + return &out, nil +} + +// DescribeTransitGatewayVPCAttachments returns attachments matching ids. +func (m *Mock) DescribeTransitGatewayVPCAttachments(_ context.Context, ids []string) ([]driver.TransitGatewayVPCAttachment, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.tgwAttachments, ids, cloneTGWAttachment), nil +} + +// CreateTransitGatewayRouteTable creates a route table on a transit gateway. +func (m *Mock) CreateTransitGatewayRouteTable( + _ context.Context, transitGatewayID string, tags map[string]string, +) (*driver.TransitGatewayRouteTable, error) { + if !m.transitGateways.Has(transitGatewayID) { + return nil, errors.Newf(errors.InvalidArgument, "transit gateway %q not found", transitGatewayID) + } + + rt := &driver.TransitGatewayRouteTable{ + ID: idgen.GenerateID("tgw-rtb-"), + TransitGatewayID: transitGatewayID, + State: "available", + Tags: copyTags(tags), + } + m.tgwRouteTables.Set(rt.ID, rt) + + out := cloneTGWRouteTable(rt) + + return &out, nil +} + +// DeleteTransitGatewayRouteTable deletes a transit gateway route table. +func (m *Mock) DeleteTransitGatewayRouteTable(_ context.Context, id string) (*driver.TransitGatewayRouteTable, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rt, ok := m.tgwRouteTables.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "transit gateway route table %q not found", id) + } + + rt.State = NATStateDeleted + + m.tgwRouteTables.Delete(id) + + out := cloneTGWRouteTable(rt) + + return &out, nil +} + +// DescribeTransitGatewayRouteTables returns route tables matching ids. +func (m *Mock) DescribeTransitGatewayRouteTables(_ context.Context, ids []string) ([]driver.TransitGatewayRouteTable, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.tgwRouteTables, ids, cloneTGWRouteTable), nil +} + +// transitGatewayInUse reports whether any attachment or route table still +// references the transit gateway. Caller must hold the lock. +func (m *Mock) transitGatewayInUse(id string) bool { + for _, att := range m.tgwAttachments.SortedValues() { + if att.TransitGatewayID == id { + return true + } + } + + for _, rt := range m.tgwRouteTables.SortedValues() { + if rt.TransitGatewayID == id { + return true + } + } + + return false +} + +func tgwRouteKey(routeTableID, cidr string) string { return routeTableID + "|" + cidr } + +func tgwAssocKey(routeTableID, attachmentID string) string { return routeTableID + "|" + attachmentID } + +// CreateTransitGatewayRoute adds a static route to a TGW route table. +func (m *Mock) CreateTransitGatewayRoute( + _ context.Context, routeTableID, destinationCIDR, attachmentID string, +) (*driver.TransitGatewayRoute, error) { + if !m.tgwRouteTables.Has(routeTableID) { + return nil, errors.Newf(errors.InvalidArgument, "transit gateway route table %q not found", routeTableID) + } + + if attachmentID != "" && !m.tgwAttachments.Has(attachmentID) { + return nil, errors.Newf(errors.InvalidArgument, "transit gateway attachment %q not found", attachmentID) + } + + route := &driver.TransitGatewayRoute{ + DestinationCIDR: destinationCIDR, + AttachmentID: attachmentID, + Type: "static", + State: "active", + } + m.tgwRoutes.Set(tgwRouteKey(routeTableID, destinationCIDR), route) + + out := *route + + return &out, nil +} + +// DeleteTransitGatewayRoute removes a route from a TGW route table. +func (m *Mock) DeleteTransitGatewayRoute( + _ context.Context, routeTableID, destinationCIDR string, +) (*driver.TransitGatewayRoute, error) { + m.mu.Lock() + defer m.mu.Unlock() + + key := tgwRouteKey(routeTableID, destinationCIDR) + + route, ok := m.tgwRoutes.Get(key) + if !ok { + return nil, errors.Newf(errors.NotFound, "transit gateway route %q not found", destinationCIDR) + } + + route.State = NATStateDeleted + + m.tgwRoutes.Delete(key) + + out := *route + + return &out, nil +} + +// SearchTransitGatewayRoutes returns the routes of a TGW route table. +func (m *Mock) SearchTransitGatewayRoutes(_ context.Context, routeTableID string) ([]driver.TransitGatewayRoute, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.tgwRouteTables.Has(routeTableID) { + return nil, errors.Newf(errors.InvalidArgument, "transit gateway route table %q not found", routeTableID) + } + + prefix := routeTableID + "|" + + var out []driver.TransitGatewayRoute + + for _, k := range m.tgwRoutes.Keys() { + if strings.HasPrefix(k, prefix) { + if r, ok := m.tgwRoutes.Get(k); ok { + out = append(out, *r) + } + } + } + + return out, nil +} + +// AssociateTransitGatewayRouteTable associates an attachment with a route table. +func (m *Mock) AssociateTransitGatewayRouteTable( + _ context.Context, routeTableID, attachmentID string, +) (*driver.TransitGatewayRouteTableAssociation, error) { + if !m.tgwRouteTables.Has(routeTableID) { + return nil, errors.Newf(errors.InvalidArgument, "transit gateway route table %q not found", routeTableID) + } + + att, ok := m.tgwAttachments.Get(attachmentID) + if !ok { + return nil, errors.Newf(errors.InvalidArgument, "transit gateway attachment %q not found", attachmentID) + } + + assoc := &driver.TransitGatewayRouteTableAssociation{ + RouteTableID: routeTableID, + AttachmentID: attachmentID, + ResourceID: att.VPCID, + ResourceType: "vpc", + State: "associated", + } + m.tgwAssociations.Set(tgwAssocKey(routeTableID, attachmentID), assoc) + + out := *assoc + + return &out, nil +} + +// EnableTransitGatewayRouteTablePropagation enables propagation from an +// attachment into a route table. +func (m *Mock) EnableTransitGatewayRouteTablePropagation(_ context.Context, routeTableID, attachmentID string) error { + return m.requireRouteTableAndAttachment(routeTableID, attachmentID) +} + +// DisableTransitGatewayRouteTablePropagation disables propagation. +func (m *Mock) DisableTransitGatewayRouteTablePropagation(_ context.Context, routeTableID, attachmentID string) error { + return m.requireRouteTableAndAttachment(routeTableID, attachmentID) +} + +func (m *Mock) requireRouteTableAndAttachment(routeTableID, attachmentID string) error { + if !m.tgwRouteTables.Has(routeTableID) || !m.tgwAttachments.Has(attachmentID) { + return errors.New(errors.InvalidArgument, "route table or attachment not found") + } + + return nil +} + +func cloneTGW(t *driver.TransitGateway) driver.TransitGateway { + c := *t + c.Tags = copyTags(t.Tags) + + return c +} + +func cloneTGWAttachment(a *driver.TransitGatewayVPCAttachment) driver.TransitGatewayVPCAttachment { + c := *a + c.SubnetIDs = append([]string(nil), a.SubnetIDs...) + c.Tags = copyTags(a.Tags) + + return c +} + +func cloneTGWRouteTable(t *driver.TransitGatewayRouteTable) driver.TransitGatewayRouteTable { + c := *t + c.Tags = copyTags(t.Tags) + + return c +} diff --git a/providers/aws/vpc/vpc.go b/providers/aws/vpc/vpc.go index 1db5188e..79ebd882 100644 --- a/providers/aws/vpc/vpc.go +++ b/providers/aws/vpc/vpc.go @@ -2,9 +2,8 @@ package vpc import ( - "sync" - "context" + "sync" "time" "github.com/stackshy/cloudemu/v2/config" @@ -26,9 +25,28 @@ const ( // interface and every call would answer InvalidAction at runtime instead of // failing the build. var ( - _ driver.Networking = (*Mock)(nil) - _ driver.NetworkInterfaces = (*Mock)(nil) - _ driver.VPCAttributes = (*Mock)(nil) + _ driver.Networking = (*Mock)(nil) + _ driver.NetworkInterfaces = (*Mock)(nil) + _ driver.VPCAttributes = (*Mock)(nil) + _ driver.TransitGateways = (*Mock)(nil) + _ driver.VPNConnections = (*Mock)(nil) + _ driver.DHCPOptionSets = (*Mock)(nil) + _ driver.PrefixLists = (*Mock)(nil) + _ driver.EgressOnlyInternetGateways = (*Mock)(nil) + _ driver.VPCEndpointServices = (*Mock)(nil) + _ driver.ClientVPN = (*Mock)(nil) + _ driver.IPAM = (*Mock)(nil) + _ driver.IPAMResources = (*Mock)(nil) + _ driver.IPAMDiscovery = (*Mock)(nil) + _ driver.IPAMByoasn = (*Mock)(nil) + _ driver.IPAMByoip = (*Mock)(nil) + _ driver.IPAMPrefixListResolver = (*Mock)(nil) + _ driver.IPAMExternalToken = (*Mock)(nil) + _ driver.IPAMPolicy = (*Mock)(nil) + _ driver.IPAMMetrics = (*Mock)(nil) + _ driver.TrafficMirroring = (*Mock)(nil) + _ driver.NetworkInsights = (*Mock)(nil) + _ driver.VPCBlockPublicAccess = (*Mock)(nil) ) // Mock is an in-memory mock implementation of the AWS VPC networking service. @@ -53,7 +71,68 @@ type Mock struct { rtAssocs *memstore.Store[*rtAssocData] enis *memstore.Store[*eniData] endpoints *memstore.Store[*driver.VPCEndpoint] - opts *config.Options + + // AWS-specific networking capabilities (optional interfaces). + transitGateways *memstore.Store[*driver.TransitGateway] + tgwAttachments *memstore.Store[*driver.TransitGatewayVPCAttachment] + tgwRouteTables *memstore.Store[*driver.TransitGatewayRouteTable] + tgwRoutes *memstore.Store[*driver.TransitGatewayRoute] + tgwAssociations *memstore.Store[*driver.TransitGatewayRouteTableAssociation] + customerGateways *memstore.Store[*driver.CustomerGateway] + vpnGateways *memstore.Store[*driver.VPNGateway] + vpnConnections *memstore.Store[*driver.VPNConnection] + dhcpOptions *memstore.Store[*driver.DHCPOptions] + prefixLists *memstore.Store[*driver.PrefixList] + egressOnlyIGWs *memstore.Store[*driver.EgressOnlyInternetGateway] + endpointServices *memstore.Store[*driver.EndpointService] + clientVPNEndpoints *memstore.Store[*driver.ClientVPNEndpoint] + clientVPNAssocs *memstore.Store[*driver.ClientVPNTargetNetwork] + clientVPNAuthRules *memstore.Store[*driver.ClientVPNAuthorizationRule] + clientVPNRoutes *memstore.Store[*driver.ClientVPNRoute] + + ipams *memstore.Store[*driver.Ipam] + ipamScopes *memstore.Store[*driver.IpamScope] + ipamPools *memstore.Store[*driver.IpamPool] + ipamPoolCidrs *memstore.Store[*driver.IpamPoolCidr] + ipamAllocations *memstore.Store[*driver.IpamPoolAllocation] + ipamDiscoveries *memstore.Store[*driver.IpamResourceDiscovery] + ipamRDAssociations *memstore.Store[*driver.IpamResourceDiscoveryAssociation] + ipamByoasns *memstore.Store[*driver.Byoasn] + ipamByoipCidrs *memstore.Store[*driver.ByoipCidr] + ipamResolvers *memstore.Store[*driver.IpamPrefixListResolver] + ipamResolverTargets *memstore.Store[*driver.IpamPrefixListResolverTarget] + ipamTokens *memstore.Store[*driver.IpamExternalResourceVerificationToken] + ipamPolicies *memstore.Store[*driver.IpamPolicy] + + // Stage B EC2-family capabilities (optional interfaces). + trafficMirrorTargets *memstore.Store[*driver.TrafficMirrorTarget] + trafficMirrorFilters *memstore.Store[*driver.TrafficMirrorFilter] + trafficMirrorSessions *memstore.Store[*driver.TrafficMirrorSession] + networkInsightsPaths *memstore.Store[*driver.NetworkInsightsPath] + networkInsightsAnalyses *memstore.Store[*driver.NetworkInsightsAnalysis] + networkInsightsAccessScopes *memstore.Store[*driver.NetworkInsightsAccessScope] + networkInsightsAccessScopeAnalyses *memstore.Store[*driver.NetworkInsightsAccessScopeAnalysis] + vpcBPAExclusions *memstore.Store[*driver.VPCBlockPublicAccessExclusion] + + // vpcBPAOptions is the account/region-level Block Public Access singleton, + // nil until first modified. Guarded by mu. + vpcBPAOptions *driver.VPCBlockPublicAccessOptions + + // endpointServicePerms holds allowed principals per endpoint-service id, + // guarded by mu. + endpointServicePerms map[string][]string + + // ipamPoolByCidr / ipamPoolByAllocation map a provisioned CIDR id and an + // allocation id to their owning pool id, guarded by mu. + ipamPoolByCidr map[string]string + ipamPoolByAllocation map[string]string + + // ipamResourceOverrides persists ModifyIpamResourceCidr scope/unmonitor + // changes, keyed by resourceID, since the base resource-CIDR list is + // re-derived from VPCs/subnets on every read. Guarded by mu. + ipamResourceOverrides map[string]ipamResourceOverride + + opts *config.Options } type vpcData struct { @@ -100,7 +179,53 @@ func New(opts *config.Options) *Mock { rtAssocs: memstore.New[*rtAssocData](), enis: memstore.New[*eniData](), endpoints: memstore.New[*driver.VPCEndpoint](), - opts: opts, + + transitGateways: memstore.New[*driver.TransitGateway](), + tgwAttachments: memstore.New[*driver.TransitGatewayVPCAttachment](), + tgwRouteTables: memstore.New[*driver.TransitGatewayRouteTable](), + tgwRoutes: memstore.New[*driver.TransitGatewayRoute](), + tgwAssociations: memstore.New[*driver.TransitGatewayRouteTableAssociation](), + customerGateways: memstore.New[*driver.CustomerGateway](), + vpnGateways: memstore.New[*driver.VPNGateway](), + vpnConnections: memstore.New[*driver.VPNConnection](), + dhcpOptions: memstore.New[*driver.DHCPOptions](), + prefixLists: memstore.New[*driver.PrefixList](), + egressOnlyIGWs: memstore.New[*driver.EgressOnlyInternetGateway](), + endpointServices: memstore.New[*driver.EndpointService](), + clientVPNEndpoints: memstore.New[*driver.ClientVPNEndpoint](), + clientVPNAssocs: memstore.New[*driver.ClientVPNTargetNetwork](), + clientVPNAuthRules: memstore.New[*driver.ClientVPNAuthorizationRule](), + clientVPNRoutes: memstore.New[*driver.ClientVPNRoute](), + + ipams: memstore.New[*driver.Ipam](), + ipamScopes: memstore.New[*driver.IpamScope](), + ipamPools: memstore.New[*driver.IpamPool](), + ipamPoolCidrs: memstore.New[*driver.IpamPoolCidr](), + ipamAllocations: memstore.New[*driver.IpamPoolAllocation](), + ipamDiscoveries: memstore.New[*driver.IpamResourceDiscovery](), + ipamRDAssociations: memstore.New[*driver.IpamResourceDiscoveryAssociation](), + ipamByoasns: memstore.New[*driver.Byoasn](), + ipamByoipCidrs: memstore.New[*driver.ByoipCidr](), + ipamResolvers: memstore.New[*driver.IpamPrefixListResolver](), + ipamResolverTargets: memstore.New[*driver.IpamPrefixListResolverTarget](), + ipamTokens: memstore.New[*driver.IpamExternalResourceVerificationToken](), + ipamPolicies: memstore.New[*driver.IpamPolicy](), + + trafficMirrorTargets: memstore.New[*driver.TrafficMirrorTarget](), + trafficMirrorFilters: memstore.New[*driver.TrafficMirrorFilter](), + trafficMirrorSessions: memstore.New[*driver.TrafficMirrorSession](), + networkInsightsPaths: memstore.New[*driver.NetworkInsightsPath](), + networkInsightsAnalyses: memstore.New[*driver.NetworkInsightsAnalysis](), + networkInsightsAccessScopes: memstore.New[*driver.NetworkInsightsAccessScope](), + networkInsightsAccessScopeAnalyses: memstore.New[*driver.NetworkInsightsAccessScopeAnalysis](), + vpcBPAExclusions: memstore.New[*driver.VPCBlockPublicAccessExclusion](), + + endpointServicePerms: map[string][]string{}, + ipamPoolByCidr: map[string]string{}, + ipamPoolByAllocation: map[string]string{}, + ipamResourceOverrides: map[string]ipamResourceOverride{}, + + opts: opts, } } @@ -202,6 +327,12 @@ func (m *Mock) DescribeVPCs(_ context.Context, ids []string) ([]driver.VPCInfo, m.mu.RLock() defer m.mu.RUnlock() + for _, id := range ids { + if !m.vpcs.Has(id) { + return nil, errors.Newf(errors.NotFound, "vpc %q not found", id) + } + } + return describeResources(m.vpcs, ids, toVPCInfo), nil } @@ -327,13 +458,21 @@ func (m *Mock) DeleteSecurityGroup(_ context.Context, id string) error { // DescribeSecurityGroups returns security groups matching the given IDs, or all if ids is empty. func (m *Mock) DescribeSecurityGroups(_ context.Context, ids []string) ([]driver.SecurityGroupInfo, error) { + for _, id := range ids { + if !m.securityGroups.Has(id) { + return nil, errors.Newf(errors.NotFound, "security group %q not found", id) + } + } + return describeResources(m.securityGroups, ids, toSGInfo), nil } // describeResources is a generic helper for Describe* methods that list or filter by IDs. func describeResources[T any, R any](store *memstore.Store[T], ids []string, toInfo func(T) R) []R { if len(ids) == 0 { - all := store.All() + // SortedValues (not All) so no-filter Describe* output is deterministic, + // matching the repo's list-ordering contract. + all := store.SortedValues() result := make([]R, 0, len(all)) for _, item := range all { diff --git a/providers/aws/vpc/vpc_test.go b/providers/aws/vpc/vpc_test.go index 9616a350..0431c414 100644 --- a/providers/aws/vpc/vpc_test.go +++ b/providers/aws/vpc/vpc_test.go @@ -78,9 +78,10 @@ func TestDescribeVPCs(t *testing.T) { }) t.Run("nonexistent ID", func(t *testing.T) { - vpcs, err := m.DescribeVPCs(ctx, []string{"vpc-nope"}) - requireNoError(t, err) - assertEqual(t, 0, len(vpcs)) + // Real EC2 returns InvalidVpcID.NotFound for an explicit missing ID, + // not an empty success — existence checks and Terraform drift rely on it. + _, err := m.DescribeVPCs(ctx, []string{"vpc-nope"}) + assertError(t, err, true) }) _ = v2 // used to create second VPC diff --git a/providers/aws/vpc/vpcblockpublicaccess.go b/providers/aws/vpc/vpcblockpublicaccess.go new file mode 100644 index 00000000..d812185f --- /dev/null +++ b/providers/aws/vpc/vpcblockpublicaccess.go @@ -0,0 +1,198 @@ +package vpc + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +// validateBlockMode rejects an InternetGatewayBlockMode outside the EC2 enum, +// rather than storing-and-echoing an invalid value. +func validateBlockMode(mode string) error { + switch mode { + case "off", "block-bidirectional", "block-ingress": + return nil + default: + return errors.Newf(errors.InvalidArgument, "invalid InternetGatewayBlockMode %q", mode) + } +} + +// validateExclusionMode rejects an InternetGatewayExclusionMode outside the +// EC2 enum. +func validateExclusionMode(mode string) error { + switch mode { + case "allow-bidirectional", "allow-egress": + return nil + default: + return errors.Newf(errors.InvalidArgument, "invalid InternetGatewayExclusionMode %q", mode) + } +} + +// DescribeVPCBlockPublicAccessOptions returns the account/region BPA options, +// synthesizing the "off" default when they've never been modified. +func (m *Mock) DescribeVPCBlockPublicAccessOptions( + _ context.Context, +) (*driver.VPCBlockPublicAccessOptions, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if m.vpcBPAOptions == nil { + return &driver.VPCBlockPublicAccessOptions{ + AWSAccountID: m.opts.AccountID, + AWSRegion: m.opts.Region, + State: "default-state", + InternetGatewayBlockMode: "off", + ExclusionsAllowed: "allowed", + ManagedBy: "account", + }, nil + } + + out := *m.vpcBPAOptions + + return &out, nil +} + +// ModifyVPCBlockPublicAccessOptions sets the internet-gateway block mode. +func (m *Mock) ModifyVPCBlockPublicAccessOptions( + _ context.Context, internetGatewayBlockMode string, +) (*driver.VPCBlockPublicAccessOptions, error) { + if err := validateBlockMode(internetGatewayBlockMode); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.vpcBPAOptions = &driver.VPCBlockPublicAccessOptions{ + AWSAccountID: m.opts.AccountID, + AWSRegion: m.opts.Region, + State: "update-complete", + InternetGatewayBlockMode: internetGatewayBlockMode, + ExclusionsAllowed: "allowed", + ManagedBy: "account", + LastUpdateTimestamp: m.opts.Clock.Now().UTC(), + } + + out := *m.vpcBPAOptions + + return &out, nil +} + +// CreateVPCBlockPublicAccessExclusion exempts a VPC or subnet from BPA. +func (m *Mock) CreateVPCBlockPublicAccessExclusion( + _ context.Context, cfg driver.VPCBlockPublicAccessExclusionConfig, +) (*driver.VPCBlockPublicAccessExclusion, error) { + if cfg.VPCID == "" && cfg.SubnetID == "" { + return nil, errors.Newf(errors.InvalidArgument, "one of VpcId or SubnetId is required") + } + + if err := validateExclusionMode(cfg.InternetGatewayExclusionMode); err != nil { + return nil, err + } + + resourceARN, err := m.exclusionResourceARN(cfg) + if err != nil { + return nil, err + } + + now := m.opts.Clock.Now().UTC() + id := idgen.GenerateID("vpcbpa-exclude-") + e := &driver.VPCBlockPublicAccessExclusion{ + ExclusionID: id, + InternetGatewayExclusionMode: cfg.InternetGatewayExclusionMode, + ResourceARN: resourceARN, + State: "create-complete", + CreationTimestamp: now, + LastUpdateTimestamp: now, + Tags: copyTags(cfg.Tags), + } + m.vpcBPAExclusions.Set(id, e) + + out := cloneBPAExclusion(e) + + return &out, nil +} + +// exclusionResourceARN validates the referenced VPC/subnet exists and builds its +// ARN. Caller must not hold mu (memstore has its own locking). +func (m *Mock) exclusionResourceARN(cfg driver.VPCBlockPublicAccessExclusionConfig) (string, error) { + if cfg.SubnetID != "" { + if !m.subnets.Has(cfg.SubnetID) { + return "", errors.Newf(errors.NotFound, "subnet %q not found", cfg.SubnetID) + } + + return m.insightsARN("subnet", cfg.SubnetID), nil + } + + if !m.vpcs.Has(cfg.VPCID) { + return "", errors.Newf(errors.NotFound, "vpc %q not found", cfg.VPCID) + } + + return m.insightsARN("vpc", cfg.VPCID), nil +} + +// ModifyVPCBlockPublicAccessExclusion changes an exclusion's mode. +func (m *Mock) ModifyVPCBlockPublicAccessExclusion( + _ context.Context, id, internetGatewayExclusionMode string, +) (*driver.VPCBlockPublicAccessExclusion, error) { + if err := validateExclusionMode(internetGatewayExclusionMode); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + e, ok := m.vpcBPAExclusions.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "vpc block public access exclusion %q not found", id) + } + + e.InternetGatewayExclusionMode = internetGatewayExclusionMode + e.State = "update-complete" + e.LastUpdateTimestamp = m.opts.Clock.Now().UTC() + + out := cloneBPAExclusion(e) + + return &out, nil +} + +// DeleteVPCBlockPublicAccessExclusion deletes an exclusion, returning its +// final state. +func (m *Mock) DeleteVPCBlockPublicAccessExclusion( + _ context.Context, id string, +) (*driver.VPCBlockPublicAccessExclusion, error) { + m.mu.Lock() + defer m.mu.Unlock() + + e, ok := m.vpcBPAExclusions.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "vpc block public access exclusion %q not found", id) + } + + e.State = "delete-complete" + e.LastUpdateTimestamp = m.opts.Clock.Now().UTC() + out := cloneBPAExclusion(e) + + m.vpcBPAExclusions.Delete(id) + + return &out, nil +} + +// DescribeVPCBlockPublicAccessExclusions returns exclusions matching ids. +func (m *Mock) DescribeVPCBlockPublicAccessExclusions( + _ context.Context, ids []string, +) ([]driver.VPCBlockPublicAccessExclusion, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.vpcBPAExclusions, ids, cloneBPAExclusion), nil +} + +func cloneBPAExclusion(e *driver.VPCBlockPublicAccessExclusion) driver.VPCBlockPublicAccessExclusion { + out := *e + out.Tags = copyTags(e.Tags) + + return out +} diff --git a/providers/aws/vpc/vpn.go b/providers/aws/vpc/vpn.go new file mode 100644 index 00000000..df04d88b --- /dev/null +++ b/providers/aws/vpc/vpn.go @@ -0,0 +1,288 @@ +package vpc + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func orDefaultStr(v, def string) string { + if v == "" { + return def + } + + return v +} + +// CreateCustomerGateway creates a customer gateway (the on-prem VPN endpoint). +func (m *Mock) CreateCustomerGateway(_ context.Context, cfg driver.CustomerGatewayConfig) (*driver.CustomerGateway, error) { + if cfg.IPAddress == "" { + return nil, errors.New(errors.InvalidArgument, "customer gateway IP address is required") + } + + cgw := &driver.CustomerGateway{ + ID: idgen.GenerateID("cgw-"), + IPAddress: cfg.IPAddress, + BGPASN: cfg.BGPASN, + Type: orDefaultStr(cfg.Type, "ipsec.1"), + State: "available", + Tags: copyTags(cfg.Tags), + } + m.customerGateways.Set(cgw.ID, cgw) + + out := cloneCustomerGateway(cgw) + + return &out, nil +} + +// DeleteCustomerGateway deletes a customer gateway. +func (m *Mock) DeleteCustomerGateway(_ context.Context, id string) error { + if !m.customerGateways.Delete(id) { + return errors.Newf(errors.NotFound, "customer gateway %q not found", id) + } + + return nil +} + +// DescribeCustomerGateways returns customer gateways matching ids. +func (m *Mock) DescribeCustomerGateways(_ context.Context, ids []string) ([]driver.CustomerGateway, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.customerGateways, ids, cloneCustomerGateway), nil +} + +// CreateVPNGateway creates a virtual private gateway. +func (m *Mock) CreateVPNGateway(_ context.Context, cfg driver.VPNGatewayConfig) (*driver.VPNGateway, error) { + asn := cfg.AmazonSideASN + if asn == 0 { + asn = defaultAmazonSideASN + } + + vgw := &driver.VPNGateway{ + ID: idgen.GenerateID("vgw-"), + Type: orDefaultStr(cfg.Type, "ipsec.1"), + State: "available", + AmazonSideASN: asn, + Tags: copyTags(cfg.Tags), + } + m.vpnGateways.Set(vgw.ID, vgw) + + out := cloneVPNGateway(vgw) + + return &out, nil +} + +// DeleteVPNGateway deletes a virtual private gateway. +func (m *Mock) DeleteVPNGateway(_ context.Context, id string) error { + if !m.vpnGateways.Delete(id) { + return errors.Newf(errors.NotFound, "vpn gateway %q not found", id) + } + + return nil +} + +// DescribeVPNGateways returns VPN gateways matching ids. +func (m *Mock) DescribeVPNGateways(_ context.Context, ids []string) ([]driver.VPNGateway, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.vpnGateways, ids, cloneVPNGateway), nil +} + +// AttachVPNGateway attaches a VPN gateway to a VPC. +func (m *Mock) AttachVPNGateway(_ context.Context, vpnGatewayID, vpcID string) (*driver.VPNGateway, error) { + m.mu.Lock() + defer m.mu.Unlock() + + vgw, ok := m.vpnGateways.Get(vpnGatewayID) + if !ok { + return nil, errors.Newf(errors.NotFound, "vpn gateway %q not found", vpnGatewayID) + } + + if !m.vpcs.Has(vpcID) { + return nil, errors.Newf(errors.InvalidArgument, "vpc %q not found", vpcID) + } + + vgw.AttachedVPCID = vpcID + vgw.AttachmentState = "attached" + + out := cloneVPNGateway(vgw) + + return &out, nil +} + +// DetachVPNGateway detaches a VPN gateway from a VPC. +func (m *Mock) DetachVPNGateway(_ context.Context, vpnGatewayID, vpcID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + vgw, ok := m.vpnGateways.Get(vpnGatewayID) + if !ok { + return errors.Newf(errors.NotFound, "vpn gateway %q not found", vpnGatewayID) + } + + if vgw.AttachedVPCID != vpcID { + return errors.Newf(errors.InvalidArgument, "vpn gateway %q is not attached to vpc %q", vpnGatewayID, vpcID) + } + + vgw.AttachedVPCID = "" + vgw.AttachmentState = "detached" + + return nil +} + +// CreateVPNConnection creates a site-to-site VPN connection. +// +//nolint:gocritic // cfg matches the driver signature. +func (m *Mock) CreateVPNConnection(_ context.Context, cfg driver.VPNConnectionConfig) (*driver.VPNConnection, error) { + if !m.customerGateways.Has(cfg.CustomerGatewayID) { + return nil, errors.Newf(errors.InvalidArgument, "customer gateway %q not found", cfg.CustomerGatewayID) + } + + if cfg.VPNGatewayID == "" && cfg.TransitGatewayID == "" { + return nil, errors.New(errors.InvalidArgument, "a vpn gateway or transit gateway is required") + } + + if cfg.VPNGatewayID != "" && !m.vpnGateways.Has(cfg.VPNGatewayID) { + return nil, errors.Newf(errors.InvalidArgument, "vpn gateway %q not found", cfg.VPNGatewayID) + } + + if cfg.TransitGatewayID != "" && !m.transitGateways.Has(cfg.TransitGatewayID) { + return nil, errors.Newf(errors.InvalidArgument, "transit gateway %q not found", cfg.TransitGatewayID) + } + + vpn := &driver.VPNConnection{ + ID: idgen.GenerateID("vpn-"), + CustomerGatewayID: cfg.CustomerGatewayID, + VPNGatewayID: cfg.VPNGatewayID, + TransitGatewayID: cfg.TransitGatewayID, + Type: orDefaultStr(cfg.Type, "ipsec.1"), + State: "available", + StaticRoutesOnly: cfg.StaticRoutesOnly, + Tags: copyTags(cfg.Tags), + } + m.vpnConnections.Set(vpn.ID, vpn) + + out := cloneVPNConnection(vpn) + + return &out, nil +} + +// DeleteVPNConnection deletes a VPN connection. +func (m *Mock) DeleteVPNConnection(_ context.Context, id string) error { + if !m.vpnConnections.Delete(id) { + return errors.Newf(errors.NotFound, "vpn connection %q not found", id) + } + + return nil +} + +// DescribeVPNConnections returns VPN connections matching ids. +func (m *Mock) DescribeVPNConnections(_ context.Context, ids []string) ([]driver.VPNConnection, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return describeResources(m.vpnConnections, ids, cloneVPNConnection), nil +} + +// CreateVPNConnectionRoute adds a static route to a VPN connection. +func (m *Mock) CreateVPNConnectionRoute(_ context.Context, vpnConnectionID, destinationCIDR string) error { + m.mu.Lock() + defer m.mu.Unlock() + + vpn, ok := m.vpnConnections.Get(vpnConnectionID) + if !ok { + return errors.Newf(errors.NotFound, "vpn connection %q not found", vpnConnectionID) + } + + for _, rt := range vpn.Routes { + if rt.DestinationCIDR == destinationCIDR { + return nil + } + } + + vpn.Routes = append(vpn.Routes, driver.VPNConnectionRoute{DestinationCIDR: destinationCIDR, State: "available"}) + + return nil +} + +// DeleteVPNConnectionRoute removes a static route from a VPN connection. +func (m *Mock) DeleteVPNConnectionRoute(_ context.Context, vpnConnectionID, destinationCIDR string) error { + m.mu.Lock() + defer m.mu.Unlock() + + vpn, ok := m.vpnConnections.Get(vpnConnectionID) + if !ok { + return errors.Newf(errors.NotFound, "vpn connection %q not found", vpnConnectionID) + } + + kept := vpn.Routes[:0:0] + + for _, rt := range vpn.Routes { + if rt.DestinationCIDR != destinationCIDR { + kept = append(kept, rt) + } + } + + vpn.Routes = kept + + return nil +} + +// ModifyVPNConnection re-targets a VPN connection to a different gateway. +func (m *Mock) ModifyVPNConnection(_ context.Context, id, transitGatewayID, vpnGatewayID string) (*driver.VPNConnection, error) { + m.mu.Lock() + defer m.mu.Unlock() + + vpn, ok := m.vpnConnections.Get(id) + if !ok { + return nil, errors.Newf(errors.NotFound, "vpn connection %q not found", id) + } + + if transitGatewayID != "" && !m.transitGateways.Has(transitGatewayID) { + return nil, errors.Newf(errors.InvalidArgument, "transit gateway %q not found", transitGatewayID) + } + + if vpnGatewayID != "" && !m.vpnGateways.Has(vpnGatewayID) { + return nil, errors.Newf(errors.InvalidArgument, "vpn gateway %q not found", vpnGatewayID) + } + + if transitGatewayID != "" { + vpn.TransitGatewayID = transitGatewayID + vpn.VPNGatewayID = "" + } + + if vpnGatewayID != "" { + vpn.VPNGatewayID = vpnGatewayID + vpn.TransitGatewayID = "" + } + + out := cloneVPNConnection(vpn) + + return &out, nil +} + +func cloneCustomerGateway(c *driver.CustomerGateway) driver.CustomerGateway { + out := *c + out.Tags = copyTags(c.Tags) + + return out +} + +func cloneVPNGateway(v *driver.VPNGateway) driver.VPNGateway { + out := *v + out.Tags = copyTags(v.Tags) + + return out +} + +func cloneVPNConnection(v *driver.VPNConnection) driver.VPNConnection { + out := *v + out.Tags = copyTags(v.Tags) + out.Routes = append([]driver.VPNConnectionRoute(nil), v.Routes...) + + return out +} diff --git a/providers/azure/aks/agentpool_cost_test.go b/providers/azure/aks/agentpool_cost_test.go new file mode 100644 index 00000000..552c3774 --- /dev/null +++ b/providers/azure/aks/agentpool_cost_test.go @@ -0,0 +1,91 @@ +package aks + +import ( + "context" + "testing" +) + +func TestClusterTierStoredAndDefaulted(t *testing.T) { + ctx := context.Background() + + t.Run("explicit tier round-trips", func(t *testing.T) { + m := newTestMock() + cluster, err := m.CreateOrUpdateCluster(ctx, ClusterInput{ + Subscription: "sub-1", + ResourceGroup: "rg-1", + Name: "k8s-standard", + Location: "eastus", + Tier: "Standard", + }) + requireNoError(t, err) + assertEqual(t, "Standard", cluster.Tier) + + got, err := m.GetCluster(ctx, "rg-1", "k8s-standard") + requireNoError(t, err) + assertEqual(t, "Standard", got.Tier) + }) + + t.Run("empty tier defaults to Free", func(t *testing.T) { + m := newTestMock() + cluster, err := m.CreateOrUpdateCluster(ctx, ClusterInput{ + Subscription: "sub-1", + ResourceGroup: "rg-1", + Name: "k8s-free", + Location: "eastus", + }) + requireNoError(t, err) + assertEqual(t, "Free", cluster.Tier) + }) +} + +func TestAgentPoolScaleSetPriority(t *testing.T) { + ctx := context.Background() + + t.Run("spot priority round-trips", func(t *testing.T) { + m := newTestMock() + _, err := m.CreateOrUpdateCluster(ctx, ClusterInput{ + Subscription: "sub-1", + ResourceGroup: "rg-1", + Name: "k8s-spot", + Location: "eastus", + }) + requireNoError(t, err) + + pool, err := m.CreateOrUpdateAgentPool(ctx, "rg-1", "k8s-spot", AgentPoolInput{ + Name: "spotpool", + VMSize: "Standard_D2s_v3", + Count: 3, + ScaleSetPriority: "Spot", + }) + requireNoError(t, err) + assertEqual(t, "Spot", pool.ScaleSetPriority) + + got, err := m.GetAgentPool(ctx, "rg-1", "k8s-spot", "spotpool") + requireNoError(t, err) + assertEqual(t, "Spot", got.ScaleSetPriority) + + pools, err := m.ListAgentPools(ctx, "rg-1", "k8s-spot") + requireNoError(t, err) + assertEqual(t, 1, len(pools)) + assertEqual(t, "Spot", pools[0].ScaleSetPriority) + }) + + t.Run("empty priority defaults to Regular", func(t *testing.T) { + m := newTestMock() + _, err := m.CreateOrUpdateCluster(ctx, ClusterInput{ + Subscription: "sub-1", + ResourceGroup: "rg-1", + Name: "k8s-reg", + Location: "eastus", + }) + requireNoError(t, err) + + pool, err := m.CreateOrUpdateAgentPool(ctx, "rg-1", "k8s-reg", AgentPoolInput{ + Name: "regpool", + VMSize: "Standard_D2s_v3", + Count: 2, + }) + requireNoError(t, err) + assertEqual(t, "Regular", pool.ScaleSetPriority) + }) +} diff --git a/providers/azure/aks/aks.go b/providers/azure/aks/aks.go index e609204b..a83048b3 100644 --- a/providers/azure/aks/aks.go +++ b/providers/azure/aks/aks.go @@ -18,6 +18,7 @@ import ( "github.com/stackshy/cloudemu/v2/config" cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/internal/k8spki" "github.com/stackshy/cloudemu/v2/internal/memstore" "github.com/stackshy/cloudemu/v2/services/kubernetes" mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" @@ -46,8 +47,11 @@ type ManagedCluster struct { NodeResourceGroup string ProvisioningState string PowerState string - Tags map[string]string - AgentPoolNames []string + // Tier is the cluster SKU tier (Free / Standard / Premium) — the uptime-SLA + // cost input a discoverer reads from `sku.tier`. + Tier string + Tags map[string]string + AgentPoolNames []string CreatedAt time.Time UpdatedAt time.Time @@ -65,8 +69,11 @@ type AgentPool struct { Mode string OrchestratorVer string ProvisioningState string - NodeLabels map[string]string - NodeTaints []string + // ScaleSetPriority is Regular or Spot — the Spot marker a discoverer reads + // for Spot node-pool pricing. + ScaleSetPriority string + NodeLabels map[string]string + NodeTaints []string CreatedAt time.Time UpdatedAt time.Time @@ -227,7 +234,9 @@ type ClusterInput struct { KubernetesVersion string DNSPrefix string NodeResourceGroup string - Tags map[string]string + // Tier is the cluster SKU tier (Free / Standard / Premium); defaults to Free. + Tier string + Tags map[string]string // AgentPools may be nil for an empty cluster; otherwise these are the // pools shipped inline at create time (system pool typically). AgentPools []AgentPoolInput @@ -235,15 +244,16 @@ type ClusterInput struct { // AgentPoolInput captures the mutable fields of an AgentPool CreateOrUpdate. type AgentPoolInput struct { - Name string - Count int32 - VMSize string - OSDiskSizeGB int32 - OSType string - Mode string - OrchestratorVer string - NodeLabels map[string]string - NodeTaints []string + Name string + Count int32 + VMSize string + OSDiskSizeGB int32 + OSType string + Mode string + OrchestratorVer string + ScaleSetPriority string + NodeLabels map[string]string + NodeTaints []string } // CreateOrUpdateCluster creates a new managed cluster or updates an existing @@ -281,6 +291,7 @@ func (m *Mock) CreateOrUpdateCluster(_ context.Context, input ClusterInput) (*Ma cluster.FQDN = cluster.DNSPrefix + ".hcp." + defaultIfEmpty(input.Location, "eastus") + ".azmk8s.io" cluster.ProvisioningState = "Succeeded" cluster.PowerState = "Running" + cluster.Tier = defaultIfEmpty(input.Tier, "Free") cluster.Tags = copyTags(input.Tags) cluster.UpdatedAt = now @@ -357,6 +368,7 @@ func buildAgentPool(rg, cluster string, in AgentPoolInput, now time.Time) AgentP Mode: defaultIfEmpty(in.Mode, "User"), OrchestratorVer: defaultIfEmpty(in.OrchestratorVer, defaultK8sVersion), ProvisioningState: "Succeeded", + ScaleSetPriority: defaultIfEmpty(in.ScaleSetPriority, "Regular"), NodeLabels: copyLabels(in.NodeLabels), NodeTaints: copyTaints(in.NodeTaints), CreatedAt: now, @@ -729,12 +741,17 @@ func (m *Mock) Kubeconfig(rg, name string) []byte { } } + // Even on the Wave-1 fallback path (no wired data plane), advertise the + // shared cluster CA so the kubeconfig is structurally identical to EKS/GKE, + // which return a real certificate-authority-data unconditionally. Only the + // server host differs (the NOT-IMPLEMENTED sentinel). return fmt.Appendf(nil, `apiVersion: v1 kind: Config clusters: - name: %s cluster: server: https://AKS-DATAPLANE-NOT-IMPLEMENTED.cloudemu.local + certificate-authority-data: %s contexts: - name: %s context: @@ -745,7 +762,7 @@ users: - name: clusterUser_%s_%s user: token: cloudemu-stub-token -`, name, name, name, rg, name, name, rg, name) +`, name, k8spki.CertificatePEM(), name, name, rg, name, name, rg, name) } func defaultIfEmpty(v, def string) string { diff --git a/providers/azure/azure.go b/providers/azure/azure.go index 1fce0c96..2ced046d 100644 --- a/providers/azure/azure.go +++ b/providers/azure/azure.go @@ -3,6 +3,7 @@ package azure import ( "context" + "strings" "github.com/stackshy/cloudemu/v2/config" "github.com/stackshy/cloudemu/v2/providers/azure/acr" @@ -17,6 +18,7 @@ import ( "github.com/stackshy/cloudemu/v2/providers/azure/azuresql" "github.com/stackshy/cloudemu/v2/providers/azure/blobstorage" "github.com/stackshy/cloudemu/v2/providers/azure/cosmosdb" + "github.com/stackshy/cloudemu/v2/providers/azure/cosmospostgresql" "github.com/stackshy/cloudemu/v2/providers/azure/databricks" "github.com/stackshy/cloudemu/v2/providers/azure/eventgrid" "github.com/stackshy/cloudemu/v2/providers/azure/functions" @@ -49,24 +51,75 @@ func (a aksDiscovery) DiscoverClusters(ctx context.Context) ([]resourcediscovery for i := range clusters { c := clusters[i] + + props := map[string]any{} + if c.PowerState != "" { + props["powerState"] = map[string]any{"code": c.PowerState} + } + + if c.KubernetesVersion != "" { + props["kubernetesVersion"] = c.KubernetesVersion + } + + pools, err := a.m.ListAgentPools(ctx, c.ResourceGroup, c.Name) + if err != nil { + return nil, err + } + out = append(out, resourcediscovery.DiscoveredCluster{ Name: c.Name, Region: c.Location, ResourceGroup: c.ResourceGroup, Tags: c.Tags, - NodeGroups: c.AgentPoolNames, + NodeGroups: aksNodeGroups(pools), + Attrs: resourcediscovery.Attributes{ + SKUTier: c.Tier, + Properties: props, + }, }) } return out, nil } +// aksNodeGroups projects each agent pool's cost signals (vmSize as sku, +// scaleSetPriority for Spot detection, count, mode, osType) onto the +// per-node-group Attributes the walker emits. +func aksNodeGroups(pools []aks.AgentPool) []resourcediscovery.DiscoveredNodeGroup { + out := make([]resourcediscovery.DiscoveredNodeGroup, 0, len(pools)) + + for i := range pools { + p := &pools[i] + + props := map[string]any{"count": int(p.Count)} + if p.ScaleSetPriority != "" { + props["scaleSetPriority"] = p.ScaleSetPriority + } + + if p.Mode != "" { + props["mode"] = p.Mode + } + + if p.OSType != "" { + props["osType"] = p.OSType + } + + out = append(out, resourcediscovery.DiscoveredNodeGroup{ + Name: p.Name, + Attrs: resourcediscovery.Attributes{SKU: p.VMSize, Properties: props}, + }) + } + + return out +} + // Provider holds all Azure mock services. type Provider struct { BlobStorage *blobstorage.Mock VirtualMachines *virtualmachines.Mock CosmosDB *cosmosdb.Mock ManagedCassandra *managedcassandra.Mock + CosmosPostgreSQL *cosmospostgresql.Mock Functions *functions.Mock VNet *vnet.Mock Monitor *azuremonitor.Mock @@ -112,6 +165,7 @@ func New(opts ...config.Option) *Provider { VirtualMachines: virtualmachines.New(o), CosmosDB: cosmosdb.New(o), ManagedCassandra: managedcassandra.New(o), + CosmosPostgreSQL: cosmospostgresql.New(o), Functions: functions.New(o), VNet: vnet.New(o), Monitor: azuremonitor.New(o), @@ -157,14 +211,16 @@ func New(opts ...config.Option) *Provider { p.ResourceDiscovery = resourcediscovery.New( resourcediscovery.ProviderAzure, o.AccountID, o.Region, &resourcediscovery.Drivers{ - Compute: p.VirtualMachines, - Networking: p.VNet, - Storage: p.BlobStorage, - Database: p.CosmosDB, - Serverless: p.Functions, - Databricks: p.Databricks, - Kubernetes: aksDiscovery{p.AKS}, - RelationalDB: sqlDiscovery{sql: p.SQL, mysql: p.MySQLFlex, pg: p.PostgresFlex}, + Compute: p.VirtualMachines, + Networking: p.VNet, + Storage: p.BlobStorage, + Database: p.CosmosDB, + Serverless: p.Functions, + Databricks: p.Databricks, + Kubernetes: aksDiscovery{p.AKS}, + RelationalDB: sqlDiscovery{sql: p.SQL, mysql: p.MySQLFlex, pg: p.PostgresFlex}, + ScaleSets: vmssDiscovery{p.VirtualMachines}, + AppServicePlans: appServicePlanDiscovery{p.Functions}, }, ) @@ -194,7 +250,30 @@ func (d sqlDiscovery) DiscoverDatabases( out = append(out, resourcediscovery.DiscoveredDatabase{ Name: clusters[i].ID, Type: resourcediscovery.TypeSQLServer, ARN: clusters[i].ARN, Tags: clusters[i].Tags, + Attrs: resourcediscovery.Attributes{ + Properties: nonEmptyProps(map[string]any{"version": clusters[i].EngineVersion}), + }, }) + + dbs, dbErr := d.sql.ListDatabases(ctx, clusters[i].ID) + if dbErr != nil { + return nil, dbErr + } + + for j := range dbs { + db := &dbs[j] + out = append(out, resourcediscovery.DiscoveredDatabase{ + Name: db.Name, Type: resourcediscovery.TypeSQLDatabase, ARN: db.ARN, + Attrs: resourcediscovery.Attributes{ + SKU: db.SKUName, + SKUTier: db.SKUTier, + Properties: nonEmptyProps(map[string]any{ + "zoneRedundant": db.ZoneRedundant, + "currentSku": map[string]any{"name": db.SKUName, "tier": db.SKUTier}, + }), + }, + }) + } } myInsts, err := d.mysql.DescribeInstances(ctx, nil) @@ -220,21 +299,179 @@ func (d sqlDiscovery) DiscoverDatabases( out = append(out, resourcediscovery.DiscoveredDatabase{ Name: mis[i].Name, Type: resourcediscovery.TypeManagedInstance, Region: mis[i].Location, ARN: mis[i].ARN, Tags: mis[i].Tags, + Attrs: resourcediscovery.Attributes{ + SKU: mis[i].SKUName, + Properties: nonEmptyProps(map[string]any{ + "vCores": mis[i].VCores, + "storageSizeInGB": mis[i].StorageGB, + "tier": mis[i].SKUTier, + "licenseType": mis[i].LicenseType, + "storageAccountType": mis[i].StorageAccountType, + }), + }, + }) + } + + return out, nil +} + +// vmssDiscovery projects the VM Scale Sets stored on the virtualmachines mock +// onto DiscoveredScaleSet for Resource Graph. +type vmssDiscovery struct{ m *virtualmachines.Mock } + +func (v vmssDiscovery) DiscoverScaleSets(ctx context.Context) ([]resourcediscovery.DiscoveredScaleSet, error) { + sets, err := v.m.ListScaleSets(ctx) + if err != nil { + return nil, err + } + + out := make([]resourcediscovery.DiscoveredScaleSet, 0, len(sets)) + + for i := range sets { + s := &sets[i] + + profile := map[string]any{} + if s.Priority != "" { + profile["priority"] = s.Priority + } + + if s.LicenseType != "" { + profile["licenseType"] = s.LicenseType + } + + if s.OSType != "" { + profile["storageProfile"] = map[string]any{"osDisk": map[string]any{"osType": s.OSType}} + } + + props := map[string]any{} + if len(profile) > 0 { + props["virtualMachineProfile"] = profile + } + + out = append(out, resourcediscovery.DiscoveredScaleSet{ + Name: s.Name, ARN: s.ID, Region: s.Location, Tags: s.Tags, + Attrs: resourcediscovery.Attributes{ + SKU: s.SKUName, + SKUTier: s.SKUTier, + SKUCapacity: s.Capacity, + Properties: props, + }, }) } return out, nil } +// appServicePlanDiscovery projects the App Service plans stored on the functions +// mock onto DiscoveredAppServicePlan for Resource Graph. +type appServicePlanDiscovery struct{ m *functions.Mock } + +func (a appServicePlanDiscovery) DiscoverAppServicePlans( + ctx context.Context, +) ([]resourcediscovery.DiscoveredAppServicePlan, error) { + plans, err := a.m.ListAppServicePlans(ctx) + if err != nil { + return nil, err + } + + out := make([]resourcediscovery.DiscoveredAppServicePlan, 0, len(plans)) + + for i := range plans { + p := &plans[i] + + out = append(out, resourcediscovery.DiscoveredAppServicePlan{ + Name: p.Name, ARN: p.ID, Region: p.Location, Tags: p.Tags, + Attrs: resourcediscovery.Attributes{ + SKU: p.SKUName, + SKUTier: p.SKUTier, + SKUCapacity: p.Capacity, + Kind: p.Kind, + }, + }) + } + + return out, nil +} + +// flexTier derives the Azure Flexible Server SKU tier (Burstable / +// GeneralPurpose / MemoryOptimized) from the SKU name, which encodes it as a +// prefix in both the current ("Standard_B1ms") and legacy ("B_Gen5_1") naming. +// The prefix list is known-incomplete on purpose: a name outside the listed +// families (e.g. Standard_F*) returns "" as an intentional best-effort fallback +// — a pricing consumer then falls back to sku.name — so an empty tier here is +// deliberate, not a bug, and this is not meant to enumerate every Azure family. +func flexTier(skuName string) string { + switch { + case strings.HasPrefix(skuName, "Standard_B"), strings.HasPrefix(skuName, "B_"): + return "Burstable" + case strings.HasPrefix(skuName, "Standard_E"), strings.HasPrefix(skuName, "MO_"): + return "MemoryOptimized" + case strings.HasPrefix(skuName, "Standard_D"), strings.HasPrefix(skuName, "GP_"): + return "GeneralPurpose" + default: + return "" + } +} + func appendFlexServers( out []resourcediscovery.DiscoveredDatabase, insts []rdsdriver.Instance, typ string, ) []resourcediscovery.DiscoveredDatabase { for i := range insts { + ha := "Disabled" + if insts[i].MultiAZ { + ha = "ZoneRedundant" + } + out = append(out, resourcediscovery.DiscoveredDatabase{ Name: insts[i].ID, Type: typ, Region: insts[i].AvailabilityZone, ARN: insts[i].ARN, Tags: insts[i].Tags, + Attrs: resourcediscovery.Attributes{ + SKU: insts[i].InstanceClass, + SKUTier: flexTier(insts[i].InstanceClass), + Properties: nonEmptyProps(map[string]any{ + "storage": map[string]any{"storageSizeGB": insts[i].AllocatedStorage}, + "highAvailability": map[string]any{"mode": ha}, + "version": insts[i].EngineVersion, + }), + }, }) } return out } + +// nonEmptyProps prunes entries that carry no cost information so the Properties +// bag only holds attributes the resource actually has: it drops empty strings, +// zero ints, nil values, and nested map[string]any entries that are (or become, +// after their own recursive pruning) empty. Bools are intentionally preserved +// as-is, including false — real Azure ARG genuinely surfaces properties such as +// properties.zoneRedundant as a bool including false, so emitting false is +// faithful. Returns nil for an empty result. +func nonEmptyProps(m map[string]any) map[string]any { + for k, v := range m { + switch val := v.(type) { + case string: + if val == "" { + delete(m, k) + } + case int: + if val == 0 { + delete(m, k) + } + case map[string]any: + // An inner map that prunes down to nothing carries no cost info — + // drop it so an empty currentSku/storage object isn't emitted. + if nonEmptyProps(val) == nil { + delete(m, k) + } + case nil: + delete(m, k) + } + } + + if len(m) == 0 { + return nil + } + + return m +} diff --git a/providers/azure/azuresql/azuresql.go b/providers/azure/azuresql/azuresql.go index 77fc71be..0d9239f7 100644 --- a/providers/azure/azuresql/azuresql.go +++ b/providers/azure/azuresql/azuresql.go @@ -71,6 +71,9 @@ type Mock struct { managedInstances *memstore.Store[rdsdriver.ManagedInstance] managedDatabases *memstore.Store[rdsdriver.ManagedDatabase] + // logical databases on a SQL server, key = "server/name" + databases *memstore.Store[rdsdriver.Database] + opts *config.Options monitoring mondriver.Monitoring } @@ -86,6 +89,7 @@ func New(opts *config.Options) *Mock { elasticPools: memstore.New[rdsdriver.ElasticPool](), failoverGroups: memstore.New[rdsdriver.FailoverGroup](), aadAdmins: memstore.New[rdsdriver.AADAdmin](), + databases: memstore.New[rdsdriver.Database](), managedInstances: memstore.New[rdsdriver.ManagedInstance](), managedDatabases: memstore.New[rdsdriver.ManagedDatabase](), opts: opts, diff --git a/providers/azure/azuresql/databases.go b/providers/azure/azuresql/databases.go new file mode 100644 index 00000000..ec50de4c --- /dev/null +++ b/providers/azure/azuresql/databases.go @@ -0,0 +1,87 @@ +package azuresql + +import ( + "context" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +// dbKey is the storage key for a logical database: "server/name". +func dbKey(server, name string) string { return server + "/" + name } + +// CreateDatabase creates a logical database on a SQL server, implementing the +// relationaldb Databases optional capability. SKU/tier default to the common +// General Purpose Gen5 shape a discoverer can price when the request omits them. +// +//nolint:gocritic // cfg matches the Databases capability interface signature. +func (m *Mock) CreateDatabase(_ context.Context, cfg rdsdriver.DatabaseConfig) (*rdsdriver.Database, error) { + if cfg.Server == "" || cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "server and database name are required") + } + + key := dbKey(cfg.Server, cfg.Name) + if _, ok := m.databases.Get(key); ok { + return nil, cerrors.Newf(cerrors.AlreadyExists, "database %q already exists on server %q", cfg.Name, cfg.Server) + } + + skuName := cfg.SKUName + if skuName == "" { + skuName = "GP_Gen5_2" + } + + skuTier := cfg.SKUTier + if skuTier == "" { + skuTier = "GeneralPurpose" + } + + db := rdsdriver.Database{ + Server: cfg.Server, + Name: cfg.Name, + Charset: cfg.Charset, + Collation: cfg.Collation, + ARN: serverDatabaseResourceID(m.opts.Region, cfg.Server, cfg.Name), + SKUName: skuName, + SKUTier: skuTier, + ZoneRedundant: cfg.ZoneRedundant, + } + m.databases.Set(key, db) + + out := db + + return &out, nil +} + +// GetDatabase returns a logical database, or NotFound. +func (m *Mock) GetDatabase(_ context.Context, server, name string) (*rdsdriver.Database, error) { + db, ok := m.databases.Get(dbKey(server, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "database %q not found on server %q", name, server) + } + + out := db + + return &out, nil +} + +// ListDatabases returns every logical database on a server. +func (m *Mock) ListDatabases(_ context.Context, server string) ([]rdsdriver.Database, error) { + out := []rdsdriver.Database{} + + for _, db := range m.databases.SortedValues() { + if db.Server == server { + out = append(out, db) + } + } + + return out, nil +} + +// DeleteDatabase removes a logical database, or returns NotFound. +func (m *Mock) DeleteDatabase(_ context.Context, server, name string) error { + if !m.databases.Delete(dbKey(server, name)) { + return cerrors.Newf(cerrors.NotFound, "database %q not found on server %q", name, server) + } + + return nil +} diff --git a/providers/azure/azuresql/databases_cost_test.go b/providers/azure/azuresql/databases_cost_test.go new file mode 100644 index 00000000..ac6c8dae --- /dev/null +++ b/providers/azure/azuresql/databases_cost_test.go @@ -0,0 +1,113 @@ +package azuresql + +import ( + "context" + "testing" + + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" +) + +func TestCreateDatabaseCarriesSKUAndZoneRedundancy(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + db, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{ + Server: "srv1", + Name: "db1", + SKUName: "GP_Gen5_4", + SKUTier: "GeneralPurpose", + ZoneRedundant: true, + }) + requireNoError(t, err) + + assertEqual(t, "srv1", db.Server) + assertEqual(t, "db1", db.Name) + assertEqual(t, "GP_Gen5_4", db.SKUName) + assertEqual(t, "GeneralPurpose", db.SKUTier) + assertEqual(t, true, db.ZoneRedundant) + assertNotEmpty(t, db.ARN) +} + +func TestCreateDatabaseDefaultsSKU(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + db, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "srv1", Name: "db1"}) + requireNoError(t, err) + + assertEqual(t, "GP_Gen5_2", db.SKUName) + assertEqual(t, "GeneralPurpose", db.SKUTier) +} + +func TestDatabaseGetListRoundTrip(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "srv1", Name: "db1"}); err != nil { + t.Fatalf("CreateDatabase srv1/db1: %v", err) + } + + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "srv1", Name: "db2"}); err != nil { + t.Fatalf("CreateDatabase srv1/db2: %v", err) + } + + // A database on a different server must not leak into srv1's listing. + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "srv2", Name: "other"}); err != nil { + t.Fatalf("CreateDatabase srv2/other: %v", err) + } + + got, err := m.GetDatabase(ctx, "srv1", "db1") + requireNoError(t, err) + assertEqual(t, "db1", got.Name) + assertEqual(t, "srv1", got.Server) + + list, err := m.ListDatabases(ctx, "srv1") + requireNoError(t, err) + assertEqual(t, 2, len(list)) + + for _, db := range list { + assertEqual(t, "srv1", db.Server) + } + + other, err := m.ListDatabases(ctx, "srv2") + requireNoError(t, err) + assertEqual(t, 1, len(other)) +} + +func TestCreateDatabaseDuplicateRejected(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "srv1", Name: "db1"}); err != nil { + t.Fatalf("CreateDatabase: %v", err) + } + + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "srv1", Name: "db1"}); err == nil { + t.Error("duplicate CreateDatabase: expected AlreadyExists") + } +} + +func TestGetAndDeleteDatabaseMissing(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.GetDatabase(ctx, "srv1", "ghost"); err == nil { + t.Error("GetDatabase on missing database: expected NotFound") + } + + if err := m.DeleteDatabase(ctx, "srv1", "ghost"); err == nil { + t.Error("DeleteDatabase on missing database: expected NotFound") + } + + if _, err := m.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "srv1", Name: "db1"}); err != nil { + t.Fatalf("CreateDatabase: %v", err) + } + + if err := m.DeleteDatabase(ctx, "srv1", "db1"); err != nil { + t.Fatalf("DeleteDatabase: %v", err) + } + + if _, err := m.GetDatabase(ctx, "srv1", "db1"); err == nil { + t.Error("GetDatabase after delete: expected NotFound") + } +} diff --git a/providers/azure/azuresql/managedinstance.go b/providers/azure/azuresql/managedinstance.go index 369d6f5e..a4549ec9 100644 --- a/providers/azure/azuresql/managedinstance.go +++ b/providers/azure/azuresql/managedinstance.go @@ -64,10 +64,13 @@ func (m *Mock) CreateManagedInstance( SubnetID: cfg.SubnetID, VCores: orDefaultInt(cfg.VCores, miDefaultVCores), StorageGB: orDefaultInt(cfg.StorageGB, miDefaultStorage), - State: miStateReady, - FQDN: cfg.Name + ".managed.database.windows.net", - ARN: m.miARN(cfg.Name), - Tags: copyTags(cfg.Tags), + // Real Azure defaults a managed instance's backup redundancy to + // GeoRedundant when the request omits it. + StorageAccountType: orDefault(cfg.StorageAccountType, "GeoRedundant"), + State: miStateReady, + FQDN: cfg.Name + ".managed.database.windows.net", + ARN: m.miARN(cfg.Name), + Tags: copyTags(cfg.Tags), } m.managedInstances.Set(cfg.Name, mi) @@ -124,6 +127,7 @@ func (m *Mock) UpdateManagedInstance( mi.SubnetID = orDefault(cfg.SubnetID, mi.SubnetID) mi.VCores = orDefaultInt(cfg.VCores, mi.VCores) mi.StorageGB = orDefaultInt(cfg.StorageGB, mi.StorageGB) + mi.StorageAccountType = orDefault(cfg.StorageAccountType, mi.StorageAccountType) if cfg.Tags != nil { mi.Tags = copyTags(cfg.Tags) diff --git a/providers/azure/blobstorage/blobstorage.go b/providers/azure/blobstorage/blobstorage.go index 2771a6bc..d2928106 100644 --- a/providers/azure/blobstorage/blobstorage.go +++ b/providers/azure/blobstorage/blobstorage.go @@ -70,10 +70,18 @@ type containerMeta struct { // Mock is an in-memory mock implementation of Azure Blob Storage. type Mock struct { containers *memstore.Store[*containerMeta] - opts *config.Options - monitoring mondriver.Monitoring + // bucketAttrs holds Azure storage-account attributes (SKU/kind/access tier) + // per container, for the BucketAttributes discovery capability. + bucketAttrs *memstore.Store[driver.AccountAttributes] + opts *config.Options + monitoring mondriver.Monitoring } +// Compile-time check that Mock satisfies the optional BucketAttributes +// discovery capability, so a signature typo fails the build rather than +// silently failing the runtime type assertion in walkStorage. +var _ driver.BucketAttributes = (*Mock)(nil) + // SetMonitoring sets the monitoring backend for auto-metric generation. func (m *Mock) SetMonitoring(mon mondriver.Monitoring) { m.monitoring = mon @@ -104,9 +112,40 @@ func (m *Mock) emitMetric(container string, metrics map[string]float64) { // New creates a new Azure Blob Storage mock. func New(opts *config.Options) *Mock { return &Mock{ - containers: memstore.New[*containerMeta](), - opts: opts, + containers: memstore.New[*containerMeta](), + bucketAttrs: memstore.New[driver.AccountAttributes](), + opts: opts, + } +} + +// SetBucketAttributes seeds the Azure storage-account attributes (SKU/kind/ +// access tier) for a container, so tests and the ARM layer can vary them. +func (m *Mock) SetBucketAttributes(name string, attrs driver.AccountAttributes) { + m.bucketAttrs.Set(name, attrs) +} + +// BucketAttributes implements the storage BucketAttributes optional capability, +// returning the seeded attributes or the real-Azure defaults (Standard_LRS / +// StorageV2 / Hot) so a cost discoverer always sees a priceable SKU. +func (m *Mock) BucketAttributes(_ context.Context, bucket string) (driver.AccountAttributes, error) { + a, ok := m.bucketAttrs.Get(bucket) + if !ok { + return driver.AccountAttributes{SKU: "Standard_LRS", Kind: "StorageV2", AccessTier: "Hot"}, nil } + + if a.SKU == "" { + a.SKU = "Standard_LRS" + } + + if a.Kind == "" { + a.Kind = "StorageV2" + } + + if a.AccessTier == "" { + a.AccessTier = "Hot" + } + + return a, nil } // CreateBucket creates a new blob container. diff --git a/providers/azure/blobstorage/bucketattrs_test.go b/providers/azure/blobstorage/bucketattrs_test.go new file mode 100644 index 00000000..014c579b --- /dev/null +++ b/providers/azure/blobstorage/bucketattrs_test.go @@ -0,0 +1,95 @@ +package blobstorage + +import ( + "context" + "testing" + + "github.com/stackshy/cloudemu/v2/services/storage/driver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBucketAttributes_Defaults verifies an un-seeded bucket returns the +// real-Azure defaults (Standard_LRS / StorageV2 / Hot) so a cost discoverer +// always sees a priceable SKU. +func TestBucketAttributes_Defaults(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + attrs, err := m.BucketAttributes(ctx, "never-seeded") + require.NoError(t, err) + + assert.Equal(t, "Standard_LRS", attrs.SKU) + assert.Equal(t, "StorageV2", attrs.Kind) + assert.Equal(t, "Hot", attrs.AccessTier) +} + +// TestBucketAttributes_RoundTrip verifies fully-populated seeded attributes are +// returned unchanged through BucketAttributes. +func TestBucketAttributes_RoundTrip(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + m.SetBucketAttributes("mybucket", driver.AccountAttributes{ + SKU: "Premium_LRS", + Kind: "BlockBlobStorage", + AccessTier: "Cool", + }) + + attrs, err := m.BucketAttributes(ctx, "mybucket") + require.NoError(t, err) + + assert.Equal(t, "Premium_LRS", attrs.SKU) + assert.Equal(t, "BlockBlobStorage", attrs.Kind) + assert.Equal(t, "Cool", attrs.AccessTier) +} + +// TestBucketAttributes_PartialSeedFillsDefaults verifies that a partial seed +// keeps the provided fields and fills the empty ones with defaults. +func TestBucketAttributes_PartialSeedFillsDefaults(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + seed driver.AccountAttributes + wantSKU string + wantKind string + wantAccessTier string + }{ + { + name: "only SKU set", + seed: driver.AccountAttributes{SKU: "Premium_LRS"}, + wantSKU: "Premium_LRS", + wantKind: "StorageV2", + wantAccessTier: "Hot", + }, + { + name: "only kind set", + seed: driver.AccountAttributes{Kind: "BlobStorage"}, + wantSKU: "Standard_LRS", + wantKind: "BlobStorage", + wantAccessTier: "Hot", + }, + { + name: "only access tier set", + seed: driver.AccountAttributes{AccessTier: "Cool"}, + wantSKU: "Standard_LRS", + wantKind: "StorageV2", + wantAccessTier: "Cool", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newTestMock() + m.SetBucketAttributes("b", tt.seed) + + attrs, err := m.BucketAttributes(ctx, "b") + require.NoError(t, err) + + assert.Equal(t, tt.wantSKU, attrs.SKU) + assert.Equal(t, tt.wantKind, attrs.Kind) + assert.Equal(t, tt.wantAccessTier, attrs.AccessTier) + }) + } +} diff --git a/providers/azure/cosmosdb/cosmosdb.go b/providers/azure/cosmosdb/cosmosdb.go index 4703f602..a597c67c 100644 --- a/providers/azure/cosmosdb/cosmosdb.go +++ b/providers/azure/cosmosdb/cosmosdb.go @@ -55,10 +55,53 @@ type tableData struct { // Mock is an in-memory mock implementation of Azure Cosmos DB. type Mock struct { - mu sync.RWMutex - tables map[string]*tableData - opts *config.Options - monitoring mondriver.Monitoring + mu sync.RWMutex + tables map[string]*tableData + // accountAttrs holds Cosmos-account cost attributes per table, for the + // TableAttributes discovery capability. + accountAttrs map[string]driver.AccountAttributes + opts *config.Options + monitoring mondriver.Monitoring +} + +// Compile-time check that Mock satisfies the optional TableAttributes +// discovery capability, so a signature typo fails the build rather than +// silently failing the runtime type assertion in walkDatabase. +var _ driver.TableAttributes = (*Mock)(nil) + +// SetTableAttributes seeds the Cosmos-account cost attributes for a table. +func (m *Mock) SetTableAttributes(table string, attrs driver.AccountAttributes) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.accountAttrs == nil { + m.accountAttrs = make(map[string]driver.AccountAttributes) + } + + m.accountAttrs[table] = attrs +} + +// TableAttributes implements the database TableAttributes optional capability, +// returning the seeded attributes or the common defaults (GlobalDocumentDB / +// Standard offer) so a cost discoverer always sees a valid account shape. +func (m *Mock) TableAttributes(_ context.Context, table string) (driver.AccountAttributes, error) { + m.mu.RLock() + a, ok := m.accountAttrs[table] + m.mu.RUnlock() + + if !ok { + return driver.AccountAttributes{Kind: "GlobalDocumentDB", OfferType: "Standard"}, nil + } + + if a.Kind == "" { + a.Kind = "GlobalDocumentDB" + } + + if a.OfferType == "" { + a.OfferType = "Standard" + } + + return a, nil } // SetMonitoring sets the monitoring backend for auto-metric generation. diff --git a/providers/azure/cosmosdb/tableattrs_test.go b/providers/azure/cosmosdb/tableattrs_test.go new file mode 100644 index 00000000..397bc352 --- /dev/null +++ b/providers/azure/cosmosdb/tableattrs_test.go @@ -0,0 +1,93 @@ +package cosmosdb + +import ( + "context" + "testing" + + "github.com/stackshy/cloudemu/v2/services/database/driver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTableAttributes_Defaults verifies an un-seeded table returns the common +// defaults (GlobalDocumentDB / Standard offer) so a cost discoverer always sees +// a valid account shape. +func TestTableAttributes_Defaults(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + attrs, err := m.TableAttributes(ctx, "never-seeded") + require.NoError(t, err) + + assert.Equal(t, "GlobalDocumentDB", attrs.Kind) + assert.Equal(t, "Standard", attrs.OfferType) + assert.False(t, attrs.EnableFreeTier) + assert.Empty(t, attrs.Capabilities) +} + +// TestTableAttributes_RoundTrip verifies fully-populated seeded attributes are +// returned unchanged through TableAttributes. +func TestTableAttributes_RoundTrip(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + m.SetTableAttributes("events", driver.AccountAttributes{ + Kind: "MongoDB", + OfferType: "Standard", + EnableFreeTier: true, + Capabilities: []string{"EnableServerless", "EnableMongo"}, + }) + + attrs, err := m.TableAttributes(ctx, "events") + require.NoError(t, err) + + assert.Equal(t, "MongoDB", attrs.Kind) + assert.Equal(t, "Standard", attrs.OfferType) + assert.True(t, attrs.EnableFreeTier) + assert.Equal(t, []string{"EnableServerless", "EnableMongo"}, attrs.Capabilities) +} + +// TestTableAttributes_PartialSeedFillsDefaults verifies a partial seed keeps the +// provided fields and fills the empty Kind/OfferType with defaults, while +// preserving the cost flags exactly as seeded. +func TestTableAttributes_PartialSeedFillsDefaults(t *testing.T) { + ctx := context.Background() + + t.Run("empty kind and offer type fall back to defaults", func(t *testing.T) { + m := newTestMock() + m.SetTableAttributes("t", driver.AccountAttributes{ + EnableFreeTier: true, + Capabilities: []string{"EnableServerless"}, + }) + + attrs, err := m.TableAttributes(ctx, "t") + require.NoError(t, err) + + assert.Equal(t, "GlobalDocumentDB", attrs.Kind) + assert.Equal(t, "Standard", attrs.OfferType) + assert.True(t, attrs.EnableFreeTier) + assert.Equal(t, []string{"EnableServerless"}, attrs.Capabilities) + }) + + t.Run("only kind set keeps kind and defaults the offer type", func(t *testing.T) { + m := newTestMock() + m.SetTableAttributes("t", driver.AccountAttributes{Kind: "MongoDB"}) + + attrs, err := m.TableAttributes(ctx, "t") + require.NoError(t, err) + + assert.Equal(t, "MongoDB", attrs.Kind) + assert.Equal(t, "Standard", attrs.OfferType) + }) + + t.Run("only offer type set keeps offer type and defaults the kind", func(t *testing.T) { + m := newTestMock() + m.SetTableAttributes("t", driver.AccountAttributes{OfferType: "Standard"}) + + attrs, err := m.TableAttributes(ctx, "t") + require.NoError(t, err) + + assert.Equal(t, "GlobalDocumentDB", attrs.Kind) + assert.Equal(t, "Standard", attrs.OfferType) + }) +} diff --git a/providers/azure/cosmospostgresql/configurations.go b/providers/azure/cosmospostgresql/configurations.go new file mode 100644 index 00000000..15401140 --- /dev/null +++ b/providers/azure/cosmospostgresql/configurations.go @@ -0,0 +1,309 @@ +package cosmospostgresql + +import ( + "context" + "sort" + "strconv" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +// configCatalog is the fixed set of well-known server parameters the mock +// exposes, with their default coordinator/node values. Real Cosmos DB for +// PostgreSQL surfaces hundreds; the mock models a representative subset so the +// Get/List/Update surface round-trips faithfully. +// +//nolint:gochecknoglobals // static server-parameter catalog +var configCatalog = map[string]struct { + dataType string + defaultValue string + allowed string + requiresRest bool + description string +}{ + "array_nulls": {"Boolean", "on", "on,off", false, "Enable input of NULL elements in arrays."}, + "max_connections": {"Integer", "300", "25-3000", true, "Maximum concurrent connections."}, + "citus.node_conninfo": {"String", "sslmode=require", "", false, "libpq connection parameters used between nodes."}, + "work_mem": {"Integer", "4096", "64-2097151", false, "Memory for internal sort/hash operations (KB)."}, +} + +func catalogNames() []string { + names := make([]string, 0, len(configCatalog)) + for name := range configCatalog { + names = append(names, name) + } + + sort.Strings(names) + + return names +} + +func configKey(rg, cluster, role, name string) string { + return rg + "/" + cluster + "/" + role + "/" + name +} + +// validateConfigValue rejects an empty value and enforces the parameter's +// AllowedValues: a comma-list is treated as an enum, a "min-max" string as an +// inclusive integer range, anything else as freeform. +func validateConfigValue(name, allowed, value string) error { + if value == "" { + return cerrors.Newf(cerrors.InvalidArgument, "value is required for configuration %q", name) + } + + switch { + case strings.Contains(allowed, ","): + for _, opt := range strings.Split(allowed, ",") { + if value == strings.TrimSpace(opt) { + return nil + } + } + + return cerrors.Newf(cerrors.InvalidArgument, "value %q for %q must be one of: %s", value, name, allowed) + case isRange(allowed): + return validateRange(name, allowed, value) + default: + return nil + } +} + +func isRange(allowed string) bool { + lo, hi, found := strings.Cut(allowed, "-") + if !found { + return false + } + + return isDigits(lo) && isDigits(hi) +} + +func isDigits(s string) bool { + if s == "" { + return false + } + + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + + return true +} + +func validateRange(name, allowed, value string) error { + loStr, hiStr, _ := strings.Cut(allowed, "-") + lo, _ := strconv.Atoi(loStr) + hi, _ := strconv.Atoi(hiStr) + + n, err := strconv.Atoi(value) + if err != nil { + return cerrors.Newf(cerrors.InvalidArgument, "value %q for %q must be an integer", value, name) + } + + if n < lo || n > hi { + return cerrors.Newf(cerrors.InvalidArgument, "value %d for %q must be within %s", n, name, allowed) + } + + return nil +} + +// storedOrDefault returns the coordinator/node value for a parameter: an +// operator-set override if present, else the catalog default. The caller holds +// a read lock. +func (m *Mock) storedOrDefault(rg, cluster, role, name string) (value, source string) { + entry := configCatalog[name] + + if sc, ok := m.serverConfigs.Get(configKey(rg, cluster, role, name)); ok { + return sc.Value, "user-override" + } + + return entry.defaultValue, "system-default" +} + +// ListConfigurations returns the cluster-wide parameters with per-role values. +func (m *Mock) ListConfigurations(_ context.Context, rg, cluster string) ([]cpgdriver.Configuration, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.clusters.Has(clusterKey(rg, cluster)) { + return nil, cerrors.Newf(cerrors.NotFound, "cluster %q not found", cluster) + } + + names := catalogNames() + out := make([]cpgdriver.Configuration, 0, len(names)) + + for _, name := range names { + out = append(out, m.configuration(rg, cluster, name)) + } + + return out, nil +} + +// GetConfiguration returns a single cluster-wide parameter. +func (m *Mock) GetConfiguration(_ context.Context, rg, cluster, name string) (*cpgdriver.Configuration, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.clusters.Has(clusterKey(rg, cluster)) { + return nil, cerrors.Newf(cerrors.NotFound, "cluster %q not found", cluster) + } + + if _, ok := configCatalog[name]; !ok { + return nil, cerrors.Newf(cerrors.NotFound, "configuration %q not found", name) + } + + c := m.configuration(rg, cluster, name) + + return &c, nil +} + +func (m *Mock) configuration(rg, cluster, name string) cpgdriver.Configuration { + entry := configCatalog[name] + + coordVal, coordSrc := m.storedOrDefault(rg, cluster, cpgdriver.RoleCoordinator, name) + nodeVal, nodeSrc := m.storedOrDefault(rg, cluster, cpgdriver.RoleWorker, name) + + return cpgdriver.Configuration{ + Name: name, + ClusterName: cluster, + ResourceGroup: rg, + ProvisioningState: cpgdriver.ProvisioningSucceeded, + Description: entry.description, + DataType: entry.dataType, + AllowedValues: entry.allowed, + RequiresRestart: entry.requiresRest, + RoleGroups: []cpgdriver.RoleGroupValue{ + {Role: cpgdriver.RoleCoordinator, Value: coordVal, DefaultValue: entry.defaultValue, Source: coordSrc}, + {Role: cpgdriver.RoleWorker, Value: nodeVal, DefaultValue: entry.defaultValue, Source: nodeSrc}, + }, + } +} + +// GetCoordinatorConfiguration returns a parameter's coordinator-role value. +func (m *Mock) GetCoordinatorConfiguration(_ context.Context, rg, cluster, name string) (*cpgdriver.ServerConfiguration, error) { + return m.getServerConfig(rg, cluster, cpgdriver.RoleCoordinator, name) +} + +// GetNodeConfiguration returns a parameter's node-role value. +func (m *Mock) GetNodeConfiguration(_ context.Context, rg, cluster, name string) (*cpgdriver.ServerConfiguration, error) { + return m.getServerConfig(rg, cluster, cpgdriver.RoleWorker, name) +} + +func (m *Mock) getServerConfig(rg, cluster, role, name string) (*cpgdriver.ServerConfiguration, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.clusters.Has(clusterKey(rg, cluster)) { + return nil, cerrors.Newf(cerrors.NotFound, "cluster %q not found", cluster) + } + + entry, ok := configCatalog[name] + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "configuration %q not found", name) + } + + value, source := m.storedOrDefault(rg, cluster, role, name) + + return &cpgdriver.ServerConfiguration{ + Name: name, + ClusterName: cluster, + ResourceGroup: rg, + ServerName: serverName(cluster, role, 0), + ProvisioningState: cpgdriver.ProvisioningSucceeded, + Value: value, + DefaultValue: entry.defaultValue, + Description: entry.description, + DataType: entry.dataType, + AllowedValues: entry.allowed, + Source: source, + RequiresRestart: entry.requiresRest, + }, nil +} + +// ListServerConfigurations returns the parameters for a specific node. +func (m *Mock) ListServerConfigurations(_ context.Context, rg, cluster, server string) ([]cpgdriver.ServerConfiguration, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.clusters.Has(clusterKey(rg, cluster)) { + return nil, cerrors.Newf(cerrors.NotFound, "cluster %q not found", cluster) + } + + role := cpgdriver.RoleWorker + if strings.HasSuffix(server, "-c") { + role = cpgdriver.RoleCoordinator + } + + names := catalogNames() + out := make([]cpgdriver.ServerConfiguration, 0, len(names)) + + for _, name := range names { + entry := configCatalog[name] + value, source := m.storedOrDefault(rg, cluster, role, name) + out = append(out, cpgdriver.ServerConfiguration{ + Name: name, + ClusterName: cluster, + ResourceGroup: rg, + ServerName: server, + ProvisioningState: cpgdriver.ProvisioningSucceeded, + Value: value, + DefaultValue: entry.defaultValue, + Description: entry.description, + DataType: entry.dataType, + AllowedValues: entry.allowed, + Source: source, + RequiresRestart: entry.requiresRest, + }) + } + + return out, nil +} + +// UpdateCoordinatorConfiguration sets a parameter's coordinator-role value. +func (m *Mock) UpdateCoordinatorConfiguration(_ context.Context, rg, cluster, name, value string) (*cpgdriver.ServerConfiguration, error) { + return m.updateServerConfig(rg, cluster, cpgdriver.RoleCoordinator, name, value) +} + +// UpdateNodeConfiguration sets a parameter's node-role value. +func (m *Mock) UpdateNodeConfiguration(_ context.Context, rg, cluster, name, value string) (*cpgdriver.ServerConfiguration, error) { + return m.updateServerConfig(rg, cluster, cpgdriver.RoleWorker, name, value) +} + +func (m *Mock) updateServerConfig(rg, cluster, role, name, value string) (*cpgdriver.ServerConfiguration, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.clusters.Has(clusterKey(rg, cluster)) { + return nil, cerrors.Newf(cerrors.NotFound, "cluster %q not found", cluster) + } + + entry, ok := configCatalog[name] + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "configuration %q not found", name) + } + + if err := validateConfigValue(name, entry.allowed, value); err != nil { + return nil, err + } + + sc := cpgdriver.ServerConfiguration{ + Name: name, + ClusterName: cluster, + ResourceGroup: rg, + ServerName: serverName(cluster, role, 0), + ProvisioningState: cpgdriver.ProvisioningSucceeded, + Value: value, + DefaultValue: entry.defaultValue, + Description: entry.description, + DataType: entry.dataType, + AllowedValues: entry.allowed, + Source: "user-override", + RequiresRestart: entry.requiresRest, + } + m.serverConfigs.Set(configKey(rg, cluster, role, name), sc) + + out := sc + + return &out, nil +} diff --git a/providers/azure/cosmospostgresql/cosmospostgresql.go b/providers/azure/cosmospostgresql/cosmospostgresql.go new file mode 100644 index 00000000..3f3c5842 --- /dev/null +++ b/providers/azure/cosmospostgresql/cosmospostgresql.go @@ -0,0 +1,662 @@ +// Package cosmospostgresql provides an in-memory mock of Azure Cosmos DB for +// PostgreSQL (Microsoft.DBforPostgreSQL/serverGroupsv2), the Citus-based +// distributed-Postgres offering. It models server-group clusters and their +// firewall rules, roles, derived nodes, server parameters (configurations), +// private-endpoint connections/links, the start/stop/restart lifecycle, and +// read-replica promotion. +package cosmospostgresql + +import ( + "context" + "strings" + "sync" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/memstore" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +const ( + providerNamespace = "Microsoft.DBforPostgreSQL" + clusterType = "serverGroupsv2" + + defaultCitusVersion = "12.1" + defaultPostgresqlVersion = "16" + defaultServerEdition = "GeneralPurpose" + defaultCoordinatorVCores = 4 + defaultNodeVCores = 4 + defaultStorageQuotaInMb = 131072 + maxNodeCount = 20 +) + +var _ cpgdriver.CosmosPostgreSQL = (*Mock)(nil) + +// Mock is the in-memory Cosmos DB for PostgreSQL implementation. All stores are +// keyed by full resource path segments (rg[/cluster[/child]]). +type Mock struct { + mu sync.RWMutex + + clusters *memstore.Store[cpgdriver.Cluster] // key = "rg/name" + firewallRules *memstore.Store[cpgdriver.FirewallRule] // key = "rg/cluster/name" + roles *memstore.Store[cpgdriver.Role] // key = "rg/cluster/name" + privateEPs *memstore.Store[cpgdriver.PrivateEndpointConnection] // key = "rg/cluster/name" + serverConfigs *memstore.Store[cpgdriver.ServerConfiguration] // key = "rg/cluster/role/name" + + opts *config.Options +} + +// New creates a new Cosmos DB for PostgreSQL mock. +func New(opts *config.Options) *Mock { + return &Mock{ + clusters: memstore.New[cpgdriver.Cluster](), + firewallRules: memstore.New[cpgdriver.FirewallRule](), + roles: memstore.New[cpgdriver.Role](), + privateEPs: memstore.New[cpgdriver.PrivateEndpointConnection](), + serverConfigs: memstore.New[cpgdriver.ServerConfiguration](), + opts: opts, + } +} + +func clusterKey(rg, name string) string { return rg + "/" + name } + +func childKey(rg, cluster, name string) string { return rg + "/" + cluster + "/" + name } + +// requireClusterLocked returns NotFound if the parent cluster doesn't exist. +// Real Azure returns 404 for a missing parent on every child operation. The +// caller holds a lock. +func (m *Mock) requireClusterLocked(rg, cluster string) error { + if !m.clusters.Has(clusterKey(rg, cluster)) { + return cerrors.Newf(cerrors.NotFound, "cosmos postgresql cluster %q not found", cluster) + } + + return nil +} + +func validName(kind, name string) error { + if name == "" { + return cerrors.Newf(cerrors.InvalidArgument, "%s name is required", kind) + } + + if strings.Contains(name, "/") { + return cerrors.Newf(cerrors.InvalidArgument, "%s name %q must not contain '/'", kind, name) + } + + return nil +} + +func copyTags(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + + out := make(map[string]string, len(src)) + for k, v := range src { + out[k] = v + } + + return out +} + +func cloneStrings(s []string) []string { + if len(s) == 0 { + return nil + } + + return append([]string(nil), s...) +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + + return v +} + +func orDefaultInt(v, def int) int { + if v == 0 { + return def + } + + return v +} + +func cloneMaintenanceWindow(in *cpgdriver.MaintenanceWindow) *cpgdriver.MaintenanceWindow { + if in == nil { + return nil + } + + out := *in + + return &out +} + +func cloneCluster(in *cpgdriver.Cluster) cpgdriver.Cluster { + c := *in + c.Tags = copyTags(c.Tags) + c.ReadReplicas = cloneStrings(c.ReadReplicas) + c.MaintenanceWindow = cloneMaintenanceWindow(c.MaintenanceWindow) + + return c +} + +// clusterResourceID builds the ARM resource ID of a cluster in this mock's +// subscription (AccountID). +func (m *Mock) clusterResourceID(rg, name string) string { + return "/subscriptions/" + m.opts.AccountID + + "/resourceGroups/" + rg + + "/providers/" + providerNamespace + "/" + clusterType + "/" + name +} + +// CreateOrUpdateCluster creates or replaces a server-group cluster. +// +//nolint:gocritic // cfg matches the driver signature. +func (m *Mock) CreateOrUpdateCluster(_ context.Context, cfg cpgdriver.CreateClusterConfig) (*cpgdriver.Cluster, bool, error) { + if err := validName("cluster", cfg.Name); err != nil { + return nil, false, err + } + + if err := validateSizing(&cfg); err != nil { + return nil, false, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + key := clusterKey(cfg.ResourceGroup, cfg.Name) + existing, isUpdate := m.clusters.Get(key) + + // A create (no existing cluster at this rg/name) must have a globally-unique + // name and, for a replica, a valid primary source. + if !isUpdate { + if err := m.ensureNameAvailableLocked(cfg.Name); err != nil { + return nil, false, err + } + + if cfg.SourceResourceID != "" { + if err := m.validateReplicaSourceLocked(cfg.SourceResourceID); err != nil { + return nil, false, err + } + } + } + + c := cpgdriver.Cluster{ + Name: cfg.Name, + ResourceGroup: cfg.ResourceGroup, + Location: cfg.Location, + Tags: copyTags(cfg.Tags), + ProvisioningState: cpgdriver.ProvisioningSucceeded, + State: "Ready", + AdministratorLogin: "citus", + CitusVersion: orDefault(cfg.CitusVersion, defaultCitusVersion), + PostgresqlVersion: orDefault(cfg.PostgresqlVersion, defaultPostgresqlVersion), + CoordinatorServerEdition: orDefault(cfg.CoordinatorServerEdition, defaultServerEdition), + CoordinatorVCores: orDefaultInt(cfg.CoordinatorVCores, defaultCoordinatorVCores), + CoordinatorStorageQuotaInMb: orDefaultInt(cfg.CoordinatorStorageQuotaInMb, defaultStorageQuotaInMb), + CoordinatorEnablePublicIPAccess: cfg.CoordinatorEnablePublicIPAccess, + EnableShardsOnCoordinator: cfg.EnableShardsOnCoordinator, + NodeServerEdition: orDefault(cfg.NodeServerEdition, defaultServerEdition), + NodeCount: cfg.NodeCount, + NodeVCores: orDefaultInt(cfg.NodeVCores, defaultNodeVCores), + NodeStorageQuotaInMb: orDefaultInt(cfg.NodeStorageQuotaInMb, defaultStorageQuotaInMb), + NodeEnablePublicIPAccess: cfg.NodeEnablePublicIPAccess, + EnableHa: cfg.EnableHa, + PreferredPrimaryZone: cfg.PreferredPrimaryZone, + MaintenanceWindow: cloneMaintenanceWindow(cfg.MaintenanceWindow), + SourceResourceID: cfg.SourceResourceID, + SourceLocation: cfg.SourceLocation, + } + + if isUpdate { + // Preserve service-computed fields, and treat the replica source as + // immutable — a re-PUT must not re-point (or corrupt) the replica graph. + c.State = existing.State + c.ReadReplicas = cloneStrings(existing.ReadReplicas) + c.SourceResourceID = existing.SourceResourceID + c.SourceLocation = existing.SourceLocation + } else if cfg.SourceResourceID != "" { + // A newly-created replica registers itself on its source cluster. + m.linkReplicaLocked(cfg.SourceResourceID, m.clusterResourceID(cfg.ResourceGroup, cfg.Name)) + } + + m.clusters.Set(key, c) + + out := cloneCluster(&c) + + return &out, !isUpdate, nil +} + +// validateSizing bounds the node count and rejects negative vCore/storage +// sizing. Zero means "use the default", so only negatives are rejected there. +func validateSizing(cfg *cpgdriver.CreateClusterConfig) error { + if cfg.NodeCount < 0 || cfg.NodeCount > maxNodeCount { + return cerrors.Newf(cerrors.InvalidArgument, "nodeCount must be between 0 and %d", maxNodeCount) + } + + if cfg.CoordinatorVCores < 0 || cfg.NodeVCores < 0 { + return cerrors.New(cerrors.InvalidArgument, "vCores must not be negative") + } + + if cfg.CoordinatorStorageQuotaInMb < 0 || cfg.NodeStorageQuotaInMb < 0 { + return cerrors.New(cerrors.InvalidArgument, "storageQuotaInMb must not be negative") + } + + return nil +} + +// ensureNameAvailableLocked rejects a create whose name is already used by any +// cluster in the subscription (Cosmos-PG names are globally unique — they form +// the coordinator FQDN). The caller holds the lock. +func (m *Mock) ensureNameAvailableLocked(name string) error { + all := m.clusters.SortedValues() + for i := range all { + if all[i].Name == name { + return cerrors.Newf(cerrors.AlreadyExists, "cluster name %q is already in use", name) + } + } + + return nil +} + +// validateReplicaSourceLocked requires the replica's source to exist and itself +// be a primary (no replica-of-a-replica chains). The caller holds the lock. +func (m *Mock) validateReplicaSourceLocked(sourceID string) error { + rg, name, ok := parseClusterID(sourceID) + if !ok { + return cerrors.Newf(cerrors.InvalidArgument, "malformed sourceResourceId %q", sourceID) + } + + src, ok := m.clusters.Get(clusterKey(rg, name)) + if !ok { + return cerrors.Newf(cerrors.NotFound, "source cluster %q not found", name) + } + + if src.SourceResourceID != "" { + return cerrors.Newf(cerrors.InvalidArgument, "cannot create a read replica of a read replica (%q)", name) + } + + return nil +} + +// linkReplicaLocked adds replicaID to the ReadReplicas of the source cluster +// identified by sourceID (a full resource ID). The caller holds the lock. +func (m *Mock) linkReplicaLocked(sourceID, replicaID string) { + rg, name, ok := parseClusterID(sourceID) + if !ok { + return + } + + src, ok := m.clusters.Get(clusterKey(rg, name)) + if !ok { + return + } + + src.ReadReplicas = append(cloneStrings(src.ReadReplicas), replicaID) + m.clusters.Set(clusterKey(rg, name), src) +} + +// parseClusterID extracts (resourceGroup, name) from a serverGroupsv2 resource +// ID. Returns ok=false if the ID isn't shaped as expected. +func parseClusterID(id string) (rg, name string, ok bool) { + parts := strings.Split(strings.Trim(id, "/"), "/") + + for i := 0; i+1 < len(parts); i++ { + switch { + case strings.EqualFold(parts[i], "resourceGroups"): + rg = parts[i+1] + case strings.EqualFold(parts[i], clusterType): + name = parts[i+1] + } + } + + if rg == "" || name == "" { + return "", "", false + } + + return rg, name, true +} + +// GetCluster returns a cluster by resource group + name. +func (m *Mock) GetCluster(_ context.Context, rg, name string) (*cpgdriver.Cluster, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + c, ok := m.clusters.Get(clusterKey(rg, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "cosmos postgresql cluster %q not found", name) + } + + out := cloneCluster(&c) + + return &out, nil +} + +// ListClustersByResourceGroup returns all clusters in a resource group. +func (m *Mock) ListClustersByResourceGroup(_ context.Context, rg string) ([]cpgdriver.Cluster, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return m.filterClusters(func(c *cpgdriver.Cluster) bool { return c.ResourceGroup == rg }), nil +} + +// ListClustersBySubscription returns all clusters (the mock serves one +// subscription). +func (m *Mock) ListClustersBySubscription(_ context.Context) ([]cpgdriver.Cluster, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return m.filterClusters(func(*cpgdriver.Cluster) bool { return true }), nil +} + +func (m *Mock) filterClusters(keep func(*cpgdriver.Cluster) bool) []cpgdriver.Cluster { + all := m.clusters.SortedValues() + out := make([]cpgdriver.Cluster, 0, len(all)) + + for i := range all { + if keep(&all[i]) { + out = append(out, cloneCluster(&all[i])) + } + } + + return out +} + +// UpdateCluster applies a PATCH to a cluster. +// +//nolint:gocritic // patch matches the driver signature. +func (m *Mock) UpdateCluster(_ context.Context, rg, name string, patch cpgdriver.ClusterPatch) (*cpgdriver.Cluster, error) { + m.mu.Lock() + defer m.mu.Unlock() + + key := clusterKey(rg, name) + + c, ok := m.clusters.Get(key) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "cosmos postgresql cluster %q not found", name) + } + + if err := applyClusterPatch(&c, &patch); err != nil { + return nil, err + } + + m.clusters.Set(key, c) + + out := cloneCluster(&c) + + return &out, nil +} + +func applyClusterPatch(c *cpgdriver.Cluster, patch *cpgdriver.ClusterPatch) error { + // A PATCH must re-validate the same bounds as create — otherwise a negative + // or huge nodeCount is stored and later crashes node derivation. + if err := validatePatchSizing(patch); err != nil { + return err + } + + if patch.Tags != nil { + c.Tags = copyTags(patch.Tags) + } + + setStr(&c.CitusVersion, patch.CitusVersion) + setStr(&c.PostgresqlVersion, patch.PostgresqlVersion) + setStr(&c.CoordinatorServerEdition, patch.CoordinatorServerEdition) + setInt(&c.CoordinatorVCores, patch.CoordinatorVCores) + setInt(&c.CoordinatorStorageQuotaInMb, patch.CoordinatorStorageQuotaInMb) + setStr(&c.NodeServerEdition, patch.NodeServerEdition) + setInt(&c.NodeCount, patch.NodeCount) + setInt(&c.NodeVCores, patch.NodeVCores) + setInt(&c.NodeStorageQuotaInMb, patch.NodeStorageQuotaInMb) + setStr(&c.PreferredPrimaryZone, patch.PreferredPrimaryZone) + + if patch.EnableHa != nil { + c.EnableHa = *patch.EnableHa + } + + if patch.CoordinatorEnablePublicIPAccess != nil { + c.CoordinatorEnablePublicIPAccess = *patch.CoordinatorEnablePublicIPAccess + } + + if patch.NodeEnablePublicIPAccess != nil { + c.NodeEnablePublicIPAccess = *patch.NodeEnablePublicIPAccess + } + + if patch.EnableShardsOnCoordinator != nil { + c.EnableShardsOnCoordinator = *patch.EnableShardsOnCoordinator + } + + // AdministratorLoginPassword is a write-only secret: accepted here but never + // stored or surfaced (the real API never returns it). + _ = patch.AdministratorLoginPassword + + if patch.MaintenanceWindow != nil { + c.MaintenanceWindow = cloneMaintenanceWindow(patch.MaintenanceWindow) + } + + return nil +} + +// validatePatchSizing bounds any sizing fields present in a PATCH. +func validatePatchSizing(patch *cpgdriver.ClusterPatch) error { + if patch.NodeCount != nil && (*patch.NodeCount < 0 || *patch.NodeCount > maxNodeCount) { + return cerrors.Newf(cerrors.InvalidArgument, "nodeCount must be between 0 and %d", maxNodeCount) + } + + for _, v := range []*int{patch.CoordinatorVCores, patch.NodeVCores} { + if v != nil && *v < 0 { + return cerrors.New(cerrors.InvalidArgument, "vCores must not be negative") + } + } + + for _, s := range []*int{patch.CoordinatorStorageQuotaInMb, patch.NodeStorageQuotaInMb} { + if s != nil && *s < 0 { + return cerrors.New(cerrors.InvalidArgument, "storageQuotaInMb must not be negative") + } + } + + return nil +} + +func setStr(dst, v *string) { + if v != nil { + *dst = *v + } +} + +func setInt(dst, v *int) { + if v != nil { + *dst = *v + } +} + +// DeleteCluster removes a cluster and cascade-deletes its children. +func (m *Mock) DeleteCluster(_ context.Context, rg, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + key := clusterKey(rg, name) + + c, ok := m.clusters.Get(key) + if !ok { + return cerrors.Newf(cerrors.NotFound, "cosmos postgresql cluster %q not found", name) + } + + // Keep replica links consistent: if this is a replica, drop it from its + // source's list; if it's a source, orphan its replicas (clear their link). + if c.SourceResourceID != "" { + m.unlinkReplicaLocked(c.SourceResourceID, m.clusterResourceID(rg, name)) + } + + m.clearReplicaSourcesLocked(c.ReadReplicas) + + prefix := rg + "/" + name + "/" + + deletePrefixed(m.firewallRules, prefix) + deletePrefixed(m.roles, prefix) + deletePrefixed(m.privateEPs, prefix) + deletePrefixed(m.serverConfigs, prefix) + m.clusters.Delete(key) + + return nil +} + +// clearReplicaSourcesLocked clears SourceResourceID/SourceLocation on each +// replica whose resource ID is listed, orphaning them when their source is +// deleted. The caller holds the lock. +func (m *Mock) clearReplicaSourcesLocked(replicaIDs []string) { + for _, id := range replicaIDs { + rg, name, ok := parseClusterID(id) + if !ok { + continue + } + + rep, ok := m.clusters.Get(clusterKey(rg, name)) + if !ok { + continue + } + + rep.SourceResourceID = "" + rep.SourceLocation = "" + m.clusters.Set(clusterKey(rg, name), rep) + } +} + +func deletePrefixed[T any](store *memstore.Store[T], prefix string) { + for _, k := range store.Keys() { + if strings.HasPrefix(k, prefix) { + store.Delete(k) + } + } +} + +// listChildren returns the store's values whose key is under rg/cluster/, +// cloned via clone. +func listChildren[T any](store *memstore.Store[T], rg, cluster string, keyOf func(*T) string, clone func(*T) T) []T { + prefix := rg + "/" + cluster + "/" + all := store.SortedValues() + out := make([]T, 0, len(all)) + + for i := range all { + if strings.HasPrefix(keyOf(&all[i]), prefix) { + out = append(out, clone(&all[i])) + } + } + + return out +} + +// RestartCluster restarts a running cluster (must be Ready). +func (m *Mock) RestartCluster(_ context.Context, rg, name string) error { + return m.transitionState(rg, name, "Ready", "Ready") +} + +// StartCluster starts a stopped cluster (must be Stopped → Ready). +func (m *Mock) StartCluster(_ context.Context, rg, name string) error { + return m.transitionState(rg, name, "Stopped", "Ready") +} + +// StopCluster stops a running cluster (must be Ready → Stopped). +func (m *Mock) StopCluster(_ context.Context, rg, name string) error { + return m.transitionState(rg, name, "Ready", "Stopped") +} + +// transitionState moves a cluster from want to next, rejecting the action when +// the cluster isn't in the expected state (real Azure 409s these). +func (m *Mock) transitionState(rg, name, want, next string) error { + m.mu.Lock() + defer m.mu.Unlock() + + key := clusterKey(rg, name) + + c, ok := m.clusters.Get(key) + if !ok { + return cerrors.Newf(cerrors.NotFound, "cosmos postgresql cluster %q not found", name) + } + + if c.State != want { + return cerrors.Newf(cerrors.FailedPrecondition, "cluster %q is %q; expected %q for this action", name, c.State, want) + } + + c.State = next + m.clusters.Set(key, c) + + return nil +} + +// PromoteReadReplica detaches a replica from its source, making it an +// independent cluster. +func (m *Mock) PromoteReadReplica(_ context.Context, rg, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + key := clusterKey(rg, name) + + c, ok := m.clusters.Get(key) + if !ok { + return cerrors.Newf(cerrors.NotFound, "cosmos postgresql cluster %q not found", name) + } + + if c.SourceResourceID == "" { + return cerrors.Newf(cerrors.FailedPrecondition, "cluster %q is not a read replica", name) + } + + m.unlinkReplicaLocked(c.SourceResourceID, m.clusterResourceID(rg, name)) + + c.SourceResourceID = "" + c.SourceLocation = "" + m.clusters.Set(key, c) + + return nil +} + +func (m *Mock) unlinkReplicaLocked(sourceID, replicaID string) { + rg, name, ok := parseClusterID(sourceID) + if !ok { + return + } + + src, ok := m.clusters.Get(clusterKey(rg, name)) + if !ok { + return + } + + kept := src.ReadReplicas[:0:0] + + for _, r := range src.ReadReplicas { + if r != replicaID { + kept = append(kept, r) + } + } + + src.ReadReplicas = kept + m.clusters.Set(clusterKey(rg, name), src) +} + +// CheckNameAvailability reports whether a cluster name is free in the +// subscription. +func (m *Mock) CheckNameAvailability(_ context.Context, name, typ string) (*cpgdriver.NameAvailability, error) { + if name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "name is required") + } + + m.mu.RLock() + defer m.mu.RUnlock() + + out := &cpgdriver.NameAvailability{ + Name: name, + Type: orDefault(typ, providerNamespace+"/"+clusterType), + NameAvailable: true, + } + + all := m.clusters.SortedValues() + for i := range all { + if all[i].Name == name { + out.NameAvailable = false + out.Message = "Name already in use." + + break + } + } + + return out, nil +} diff --git a/providers/azure/cosmospostgresql/cosmospostgresql_test.go b/providers/azure/cosmospostgresql/cosmospostgresql_test.go new file mode 100644 index 00000000..a1259b3d --- /dev/null +++ b/providers/azure/cosmospostgresql/cosmospostgresql_test.go @@ -0,0 +1,703 @@ +package cosmospostgresql + +import ( + "context" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +const sub = "sub-123" + +func newTestMock() *Mock { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + opts := config.NewOptions(config.WithClock(fc), config.WithRegion("eastus"), config.WithAccountID(sub)) + + return New(opts) +} + +func mustCluster(t *testing.T, m *Mock, rg, name string, nodeCount int) string { + t.Helper() + + if _, _, err := m.CreateOrUpdateCluster(context.Background(), cpgdriver.CreateClusterConfig{ + Name: name, ResourceGroup: rg, Location: "eastus", NodeCount: nodeCount, + }); err != nil { + t.Fatalf("CreateOrUpdateCluster %s: %v", name, err) + } + + return name +} + +func TestClusterLifecycle(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + c, _, err := m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{ + Name: "pg1", ResourceGroup: "rg1", Location: "eastus", NodeCount: 2, + Tags: map[string]string{"env": "prod"}, EnableHa: true, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + if c.ProvisioningState != cpgdriver.ProvisioningSucceeded || c.CitusVersion != defaultCitusVersion { + t.Fatalf("defaults wrong: %+v", c) + } + + // PATCH: scale nodes + change HA. + ha := false + up, err := m.UpdateCluster(ctx, "rg1", "pg1", cpgdriver.ClusterPatch{ + NodeCount: intPtr(4), EnableHa: &ha, Tags: map[string]string{"env": "dev"}, + }) + if err != nil || up.NodeCount != 4 || up.EnableHa { + t.Fatalf("patch not applied: %+v err=%v", up, err) + } + + if up.Tags["env"] != "dev" { + t.Fatalf("tags not replaced: %+v", up.Tags) + } + + // List by RG + subscription. + byRG, _ := m.ListClustersByResourceGroup(ctx, "rg1") + bySub, _ := m.ListClustersBySubscription(ctx) + + if len(byRG) != 1 || len(bySub) != 1 { + t.Fatalf("list wrong: rg=%d sub=%d", len(byRG), len(bySub)) + } + + // Stop / start toggles state. + if err := m.StopCluster(ctx, "rg1", "pg1"); err != nil { + t.Fatalf("stop: %v", err) + } + + if got, _ := m.GetCluster(ctx, "rg1", "pg1"); got.State != "Stopped" { + t.Fatalf("state after stop: %q", got.State) + } + + if err := m.StartCluster(ctx, "rg1", "pg1"); err != nil { + t.Fatalf("start: %v", err) + } + + // Delete. + if err := m.DeleteCluster(ctx, "rg1", "pg1"); err != nil { + t.Fatalf("delete: %v", err) + } + + if _, err := m.GetCluster(ctx, "rg1", "pg1"); !cerrors.IsNotFound(err) { + t.Fatalf("get after delete: got %v, want NotFound", err) + } +} + +func TestNodeCountBounded(t *testing.T) { + m := newTestMock() + + if _, _, err := m.CreateOrUpdateCluster(context.Background(), cpgdriver.CreateClusterConfig{ + Name: "big", ResourceGroup: "rg1", NodeCount: maxNodeCount + 1, + }); !cerrors.IsInvalidArgument(err) { + t.Fatalf("oversized nodeCount: got %v, want InvalidArgument", err) + } +} + +func TestFirewallRulesAndRoles(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "pg1", 2) + + // A firewall rule under a missing cluster is rejected with NotFound. + if _, err := m.CreateOrUpdateFirewallRule(ctx, cpgdriver.CreateFirewallRuleConfig{ + ResourceGroup: "rg1", ClusterName: "ghost", Name: "all", StartIPAddress: "0.0.0.0", EndIPAddress: "255.255.255.255", + }); !cerrors.IsNotFound(err) { + t.Fatalf("fw under missing cluster: got %v, want NotFound", err) + } + + if _, err := m.CreateOrUpdateFirewallRule(ctx, cpgdriver.CreateFirewallRuleConfig{ + ResourceGroup: "rg1", ClusterName: "pg1", Name: "all", StartIPAddress: "0.0.0.0", EndIPAddress: "255.255.255.255", + }); err != nil { + t.Fatalf("CreateOrUpdateFirewallRule: %v", err) + } + + rules, _ := m.ListFirewallRules(ctx, "rg1", "pg1") + if len(rules) != 1 || rules[0].EndIPAddress != "255.255.255.255" { + t.Fatalf("list fw wrong: %+v", rules) + } + + if _, err := m.CreateRole(ctx, cpgdriver.CreateRoleConfig{ResourceGroup: "rg1", ClusterName: "pg1", Name: "app", Password: "R0lePass!"}); err != nil { + t.Fatalf("CreateRole: %v", err) + } + + if _, err := m.CreateRole(ctx, cpgdriver.CreateRoleConfig{ + ResourceGroup: "rg1", ClusterName: "pg1", Name: "app", Password: "R0lePass!", + }); !cerrors.IsAlreadyExists(err) { + t.Fatalf("duplicate role: got %v, want AlreadyExists", err) + } + + // Cascade delete removes children. + if err := m.DeleteCluster(ctx, "rg1", "pg1"); err != nil { + t.Fatalf("delete cluster: %v", err) + } + + if r, _ := m.ListFirewallRules(ctx, "rg1", "pg1"); len(r) != 0 { + t.Fatalf("firewall rules survived cluster delete: %+v", r) + } +} + +func TestDerivedServers(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "pg1", 2) + + servers, err := m.ListServers(ctx, "rg1", "pg1") + if err != nil { + t.Fatalf("ListServers: %v", err) + } + + // One coordinator + two workers. + if len(servers) != 3 || servers[0].Role != cpgdriver.RoleCoordinator { + t.Fatalf("derived nodes wrong: %+v", servers) + } + + if _, err := m.GetServer(ctx, "rg1", "pg1", "pg1-c"); err != nil { + t.Fatalf("GetServer coordinator: %v", err) + } + + if _, err := m.GetServer(ctx, "rg1", "pg1", "pg1-w9"); !cerrors.IsNotFound(err) { + t.Fatalf("GetServer missing: got %v, want NotFound", err) + } +} + +func TestConfigurations(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "pg1", 1) + + // Default value comes from the catalog. + sc, err := m.GetCoordinatorConfiguration(ctx, "rg1", "pg1", "max_connections") + if err != nil || sc.Value != "300" || sc.Source != "system-default" { + t.Fatalf("default coordinator config wrong: %+v err=%v", sc, err) + } + + // Update overrides the coordinator value only. + if _, err := m.UpdateCoordinatorConfiguration(ctx, "rg1", "pg1", "max_connections", "500"); err != nil { + t.Fatalf("UpdateCoordinatorConfiguration: %v", err) + } + + sc, _ = m.GetCoordinatorConfiguration(ctx, "rg1", "pg1", "max_connections") + if sc.Value != "500" || sc.Source != "user-override" { + t.Fatalf("coordinator override not applied: %+v", sc) + } + + node, _ := m.GetNodeConfiguration(ctx, "rg1", "pg1", "max_connections") + if node.Value != "300" { + t.Fatalf("node value should be unchanged default: %+v", node) + } + + // Cluster-wide view shows both role groups. + cfg, err := m.GetConfiguration(ctx, "rg1", "pg1", "max_connections") + if err != nil || len(cfg.RoleGroups) != 2 { + t.Fatalf("GetConfiguration wrong: %+v err=%v", cfg, err) + } + + if _, err := m.GetConfiguration(ctx, "rg1", "pg1", "no_such_param"); !cerrors.IsNotFound(err) { + t.Fatalf("unknown config: got %v, want NotFound", err) + } +} + +func TestReadReplicaPromotion(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "primary", 2) + + srcID := m.clusterResourceID("rg1", "primary") + + // Create a replica pointing at the primary. + if _, _, err := m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{ + Name: "replica", ResourceGroup: "rg1", Location: "westus", + SourceResourceID: srcID, SourceLocation: "eastus", + }); err != nil { + t.Fatalf("create replica: %v", err) + } + + // The primary now lists the replica. + primary, _ := m.GetCluster(ctx, "rg1", "primary") + if len(primary.ReadReplicas) != 1 { + t.Fatalf("primary should list one replica: %+v", primary.ReadReplicas) + } + + // Promote detaches it. + if err := m.PromoteReadReplica(ctx, "rg1", "replica"); err != nil { + t.Fatalf("PromoteReadReplica: %v", err) + } + + rep, _ := m.GetCluster(ctx, "rg1", "replica") + if rep.SourceResourceID != "" { + t.Fatalf("replica still linked after promote: %+v", rep) + } + + primary, _ = m.GetCluster(ctx, "rg1", "primary") + if len(primary.ReadReplicas) != 0 { + t.Fatalf("primary should have no replicas after promote: %+v", primary.ReadReplicas) + } + + // Promoting a non-replica fails. + if err := m.PromoteReadReplica(ctx, "rg1", "primary"); !cerrors.IsFailedPrecondition(err) { + t.Fatalf("promote non-replica: got %v, want FailedPrecondition", err) + } +} + +func TestCheckNameAvailability(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + na, err := m.CheckNameAvailability(ctx, "free", "") + if err != nil || !na.NameAvailable { + t.Fatalf("free name should be available: %+v err=%v", na, err) + } + + mustCluster(t, m, "rg1", "taken", 1) + + na, _ = m.CheckNameAvailability(ctx, "taken", "") + if na.NameAvailable { + t.Fatalf("taken name should be unavailable: %+v", na) + } +} + +func TestClusterResultDoesNotAliasStore(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, _, err := m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{ + Name: "pg1", ResourceGroup: "rg1", Tags: map[string]string{"env": "prod"}, + MaintenanceWindow: &cpgdriver.MaintenanceWindow{DayOfWeek: 1, StartHour: 2}, + }); err != nil { + t.Fatalf("create: %v", err) + } + + got, _ := m.GetCluster(ctx, "rg1", "pg1") + got.Tags["env"] = "hacked" + got.MaintenanceWindow.StartHour = 23 + + again, _ := m.GetCluster(ctx, "rg1", "pg1") + if again.Tags["env"] != "prod" || again.MaintenanceWindow.StartHour != 2 { + t.Fatal("returned cluster aliases the store (clone-on-read broken)") + } +} + +func intPtr(i int) *int { return &i } + +func TestPatchNodeCountValidated(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "pg1", 2) + + // A negative PATCH nodeCount must be rejected (would otherwise store a bad + // cap and crash node derivation). + if _, err := m.UpdateCluster(ctx, "rg1", "pg1", cpgdriver.ClusterPatch{NodeCount: intPtr(-2)}); !cerrors.IsInvalidArgument(err) { + t.Fatalf("negative PATCH nodeCount: got %v, want InvalidArgument", err) + } + + if _, err := m.UpdateCluster(ctx, "rg1", "pg1", cpgdriver.ClusterPatch{NodeCount: intPtr(maxNodeCount + 1)}); !cerrors.IsInvalidArgument(err) { + t.Fatalf("oversized PATCH nodeCount: got %v, want InvalidArgument", err) + } + + // The stored cluster is unchanged, and node derivation still works. + if _, err := m.ListServers(ctx, "rg1", "pg1"); err != nil { + t.Fatalf("ListServers after rejected patch: %v", err) + } +} + +func TestClusterNameGloballyUnique(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "pg1", 1) + + // Same name in a different resource group is rejected. + if _, _, err := m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{ + Name: "pg1", ResourceGroup: "rg2", Location: "eastus", + }); !cerrors.IsAlreadyExists(err) { + t.Fatalf("duplicate name across RGs: got %v, want AlreadyExists", err) + } + + // Re-PUT of the same rg/name is an update, not a conflict. + if _, _, err := m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{ + Name: "pg1", ResourceGroup: "rg1", Location: "eastus", NodeCount: 3, + }); err != nil { + t.Fatalf("re-PUT same cluster: %v", err) + } +} + +func TestReplicaSourceValidated(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "primary", 2) + + // A bogus source is rejected. + if _, _, err := m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{ + Name: "rep1", ResourceGroup: "rg1", SourceResourceID: m.clusterResourceID("rg1", "ghost"), + }); !cerrors.IsNotFound(err) { + t.Fatalf("bogus replica source: got %v, want NotFound", err) + } + + // A valid replica, then a replica-of-a-replica is rejected. + if _, _, err := m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{ + Name: "rep1", ResourceGroup: "rg1", SourceResourceID: m.clusterResourceID("rg1", "primary"), + }); err != nil { + t.Fatalf("valid replica: %v", err) + } + + if _, _, err := m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{ + Name: "rep2", ResourceGroup: "rg1", SourceResourceID: m.clusterResourceID("rg1", "rep1"), + }); !cerrors.IsInvalidArgument(err) { + t.Fatalf("chained replica: got %v, want InvalidArgument", err) + } +} + +func TestDeleteUnlinksReplicas(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "primary", 2) + + if _, _, err := m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{ + Name: "rep1", ResourceGroup: "rg1", SourceResourceID: m.clusterResourceID("rg1", "primary"), + }); err != nil { + t.Fatalf("create replica: %v", err) + } + + // Deleting the source orphans the replica (clears its SourceResourceID). + if err := m.DeleteCluster(ctx, "rg1", "primary"); err != nil { + t.Fatalf("delete primary: %v", err) + } + + rep, _ := m.GetCluster(ctx, "rg1", "rep1") + if rep.SourceResourceID != "" { + t.Fatalf("replica still links a deleted source: %+v", rep.SourceResourceID) + } +} + +func TestDeleteReplicaUnlinksFromSource(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "primary", 2) + + if _, _, err := m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{ + Name: "rep1", ResourceGroup: "rg1", SourceResourceID: m.clusterResourceID("rg1", "primary"), + }); err != nil { + t.Fatalf("create replica: %v", err) + } + + if err := m.DeleteCluster(ctx, "rg1", "rep1"); err != nil { + t.Fatalf("delete replica: %v", err) + } + + primary, _ := m.GetCluster(ctx, "rg1", "primary") + if len(primary.ReadReplicas) != 0 { + t.Fatalf("source still lists a deleted replica: %+v", primary.ReadReplicas) + } +} + +func TestFirewallIPValidation(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "pg1", 1) + + // Non-IPv4. + if _, err := m.CreateOrUpdateFirewallRule(ctx, cpgdriver.CreateFirewallRuleConfig{ + ResourceGroup: "rg1", ClusterName: "pg1", Name: "bad", StartIPAddress: "not-an-ip", EndIPAddress: "1.2.3.4", + }); !cerrors.IsInvalidArgument(err) { + t.Fatalf("bad IP: got %v, want InvalidArgument", err) + } + + // Reversed range. + if _, err := m.CreateOrUpdateFirewallRule(ctx, cpgdriver.CreateFirewallRuleConfig{ + ResourceGroup: "rg1", ClusterName: "pg1", Name: "rev", StartIPAddress: "203.0.113.50", EndIPAddress: "203.0.113.10", + }); !cerrors.IsInvalidArgument(err) { + t.Fatalf("reversed range: got %v, want InvalidArgument", err) + } +} + +func TestListChildrenRequireParent(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.ListFirewallRules(ctx, "rg1", "ghost"); !cerrors.IsNotFound(err) { + t.Fatalf("list fw missing parent: got %v, want NotFound", err) + } + + if _, err := m.ListRoles(ctx, "rg1", "ghost"); !cerrors.IsNotFound(err) { + t.Fatalf("list roles missing parent: got %v, want NotFound", err) + } + + if _, err := m.ListPrivateEndpointConnections(ctx, "rg1", "ghost"); !cerrors.IsNotFound(err) { + t.Fatalf("list PE missing parent: got %v, want NotFound", err) + } +} + +func TestClusterStateGuards(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "pg1", 1) + + // Can't start an already-running cluster. + if err := m.StartCluster(ctx, "rg1", "pg1"); !cerrors.IsFailedPrecondition(err) { + t.Fatalf("start running cluster: got %v, want FailedPrecondition", err) + } + + // Stop → can't stop again; start brings it back. + if err := m.StopCluster(ctx, "rg1", "pg1"); err != nil { + t.Fatalf("stop: %v", err) + } + + if err := m.StopCluster(ctx, "rg1", "pg1"); !cerrors.IsFailedPrecondition(err) { + t.Fatalf("stop stopped cluster: got %v, want FailedPrecondition", err) + } + + if err := m.StartCluster(ctx, "rg1", "pg1"); err != nil { + t.Fatalf("start: %v", err) + } + + if err := m.RestartCluster(ctx, "rg1", "pg1"); err != nil { + t.Fatalf("restart running cluster: %v", err) + } +} + +func TestConfigValueValidation(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "pg1", 1) + + // Empty value rejected. + if _, err := m.UpdateCoordinatorConfiguration(ctx, "rg1", "pg1", "max_connections", ""); !cerrors.IsInvalidArgument(err) { + t.Fatalf("empty value: got %v, want InvalidArgument", err) + } + + // Out-of-range integer rejected (max_connections range is 25-3000). + if _, err := m.UpdateCoordinatorConfiguration(ctx, "rg1", "pg1", "max_connections", "999999"); !cerrors.IsInvalidArgument(err) { + t.Fatalf("out-of-range: got %v, want InvalidArgument", err) + } + + // Enum violation rejected (array_nulls allows on,off). + if _, err := m.UpdateNodeConfiguration(ctx, "rg1", "pg1", "array_nulls", "maybe"); !cerrors.IsInvalidArgument(err) { + t.Fatalf("enum violation: got %v, want InvalidArgument", err) + } + + // A valid in-range value is accepted. + if _, err := m.UpdateCoordinatorConfiguration(ctx, "rg1", "pg1", "max_connections", "500"); err != nil { + t.Fatalf("valid value: %v", err) + } +} + +func TestRolePasswordRequired(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "pg1", 1) + + if _, err := m.CreateRole(ctx, cpgdriver.CreateRoleConfig{ResourceGroup: "rg1", ClusterName: "pg1", Name: "app"}); !cerrors.IsInvalidArgument(err) { + t.Fatalf("role without password: got %v, want InvalidArgument", err) + } +} + +func TestFirewallAndRoleGetDelete(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "pg1", 1) + + if _, err := m.CreateOrUpdateFirewallRule(ctx, cpgdriver.CreateFirewallRuleConfig{ + ResourceGroup: "rg1", ClusterName: "pg1", Name: "fw", StartIPAddress: "10.0.0.0", EndIPAddress: "10.0.0.255", + }); err != nil { + t.Fatalf("create fw: %v", err) + } + + if _, err := m.GetFirewallRule(ctx, "rg1", "pg1", "fw"); err != nil { + t.Fatalf("GetFirewallRule: %v", err) + } + + if err := m.DeleteFirewallRule(ctx, "rg1", "pg1", "fw"); err != nil { + t.Fatalf("DeleteFirewallRule: %v", err) + } + + if _, err := m.GetFirewallRule(ctx, "rg1", "pg1", "fw"); !cerrors.IsNotFound(err) { + t.Fatalf("get deleted fw: got %v, want NotFound", err) + } + + if _, err := m.CreateRole(ctx, cpgdriver.CreateRoleConfig{ResourceGroup: "rg1", ClusterName: "pg1", Name: "app", Password: "R0lePass!"}); err != nil { + t.Fatalf("create role: %v", err) + } + + if _, err := m.GetRole(ctx, "rg1", "pg1", "app"); err != nil { + t.Fatalf("GetRole: %v", err) + } + + if err := m.DeleteRole(ctx, "rg1", "pg1", "app"); err != nil { + t.Fatalf("DeleteRole: %v", err) + } + + if err := m.DeleteRole(ctx, "rg1", "pg1", "app"); !cerrors.IsNotFound(err) { + t.Fatalf("delete missing role: got %v, want NotFound", err) + } +} + +func TestConfigurationsListingAndServerConfigs(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "pg1", 1) + + cfgs, err := m.ListConfigurations(ctx, "rg1", "pg1") + if err != nil || len(cfgs) == 0 { + t.Fatalf("ListConfigurations: %v len=%d", err, len(cfgs)) + } + + scs, err := m.ListServerConfigurations(ctx, "rg1", "pg1", "pg1-c") + if err != nil || len(scs) == 0 { + t.Fatalf("ListServerConfigurations: %v len=%d", err, len(scs)) + } + + if _, err := m.GetNodeConfiguration(ctx, "rg1", "pg1", "work_mem"); err != nil { + t.Fatalf("GetNodeConfiguration: %v", err) + } + + // Listing under a missing cluster is NotFound. + if _, err := m.ListConfigurations(ctx, "rg1", "ghost"); !cerrors.IsNotFound(err) { + t.Fatalf("ListConfigurations missing parent: got %v, want NotFound", err) + } +} + +func TestPrivateEndpointsAndLinks(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "pg1", 1) + + // Invalid connection status is rejected. + if _, err := m.CreateOrUpdatePrivateEndpointConnection(ctx, "rg1", "pg1", "pe1", "Bogus", ""); !cerrors.IsInvalidArgument(err) { + t.Fatalf("bad PE status: got %v, want InvalidArgument", err) + } + + pec, err := m.CreateOrUpdatePrivateEndpointConnection(ctx, "rg1", "pg1", "pe1", "Approved", "ok") + if err != nil || pec.ActionsRequired != "None" { + t.Fatalf("create PE: %v %+v", err, pec) + } + + if _, err := m.GetPrivateEndpointConnection(ctx, "rg1", "pg1", "pe1"); err != nil { + t.Fatalf("GetPrivateEndpointConnection: %v", err) + } + + pecs, _ := m.ListPrivateEndpointConnections(ctx, "rg1", "pg1") + if len(pecs) != 1 { + t.Fatalf("ListPrivateEndpointConnections: got %d, want 1", len(pecs)) + } + + if err := m.DeletePrivateEndpointConnection(ctx, "rg1", "pg1", "pe1"); err != nil { + t.Fatalf("DeletePrivateEndpointConnection: %v", err) + } + + // Private-link resources: one "coordinator" group. + plrs, err := m.ListPrivateLinkResources(ctx, "rg1", "pg1") + if err != nil || len(plrs) != 1 { + t.Fatalf("ListPrivateLinkResources: %v len=%d", err, len(plrs)) + } + + if _, err := m.GetPrivateLinkResource(ctx, "rg1", "pg1", "coordinator"); err != nil { + t.Fatalf("GetPrivateLinkResource: %v", err) + } + + if _, err := m.GetPrivateLinkResource(ctx, "rg1", "pg1", "nope"); !cerrors.IsNotFound(err) { + t.Fatalf("GetPrivateLinkResource missing: got %v, want NotFound", err) + } +} + +func TestReplicaSourceImmutableOnRePut(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "primary", 2) + mustCluster(t, m, "rg1", "other", 2) + + if _, _, err := m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{ + Name: "replica", ResourceGroup: "rg1", SourceResourceID: m.clusterResourceID("rg1", "primary"), + }); err != nil { + t.Fatalf("create replica: %v", err) + } + + // Re-PUT the replica trying to re-point it at "other": the source is + // immutable, so the link graph must not change. + if _, _, err := m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{ + Name: "replica", ResourceGroup: "rg1", SourceResourceID: m.clusterResourceID("rg1", "other"), + }); err != nil { + t.Fatalf("re-PUT replica: %v", err) + } + + rep, _ := m.GetCluster(ctx, "rg1", "replica") + if rep.SourceResourceID != m.clusterResourceID("rg1", "primary") { + t.Fatalf("replica source changed on re-PUT: %q", rep.SourceResourceID) + } + + other, _ := m.GetCluster(ctx, "rg1", "other") + if len(other.ReadReplicas) != 0 { + t.Fatalf("re-PUT wrongly linked 'other': %+v", other.ReadReplicas) + } + + primary, _ := m.GetCluster(ctx, "rg1", "primary") + if len(primary.ReadReplicas) != 1 { + t.Fatalf("primary lost its replica link: %+v", primary.ReadReplicas) + } +} + +func TestCreatedFlag(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, created, err := m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{Name: "pg1", ResourceGroup: "rg1"}) + if err != nil || !created { + t.Fatalf("first PUT: created=%v err=%v", created, err) + } + + _, created, err = m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{Name: "pg1", ResourceGroup: "rg1"}) + if err != nil || created { + t.Fatalf("re-PUT: created=%v err=%v", created, err) + } +} + +func TestPatchAppliesWritableFields(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "pg1", 2) + + pub, shards := true, true + up, err := m.UpdateCluster(ctx, "rg1", "pg1", cpgdriver.ClusterPatch{ + CoordinatorEnablePublicIPAccess: &pub, + NodeEnablePublicIPAccess: &pub, + EnableShardsOnCoordinator: &shards, + NodeCount: intPtr(0), + }) + if err != nil { + t.Fatalf("UpdateCluster: %v", err) + } + + if !up.CoordinatorEnablePublicIPAccess || !up.NodeEnablePublicIPAccess || !up.EnableShardsOnCoordinator { + t.Fatalf("public-IP / shards flags not applied: %+v", up) + } + + // PATCH scale-to-single-node (nodeCount 0) must take effect. + if up.NodeCount != 0 { + t.Fatalf("scale-to-single-node not applied: %d", up.NodeCount) + } +} + +func TestReplicaNodesReadOnly(t *testing.T) { + m := newTestMock() + ctx := context.Background() + mustCluster(t, m, "rg1", "primary", 2) + + if _, _, err := m.CreateOrUpdateCluster(ctx, cpgdriver.CreateClusterConfig{ + Name: "rep1", ResourceGroup: "rg1", NodeCount: 2, SourceResourceID: m.clusterResourceID("rg1", "primary"), + }); err != nil { + t.Fatalf("create replica: %v", err) + } + + nodes, _ := m.ListServers(ctx, "rg1", "rep1") + for i := range nodes { + if !nodes[i].IsReadOnly { + t.Fatalf("replica node %q (%s) is not read-only", nodes[i].Name, nodes[i].Role) + } + } +} diff --git a/providers/azure/cosmospostgresql/firewallrules.go b/providers/azure/cosmospostgresql/firewallrules.go new file mode 100644 index 00000000..e7b51665 --- /dev/null +++ b/providers/azure/cosmospostgresql/firewallrules.go @@ -0,0 +1,115 @@ +package cosmospostgresql + +import ( + "bytes" + "context" + "net" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +// validateIPRange rejects non-IPv4 endpoints and reversed ranges, matching the +// real Azure firewall-rule validation. +func validateIPRange(start, end string) error { + s, e := net.ParseIP(start).To4(), net.ParseIP(end).To4() + if s == nil || e == nil { + return cerrors.New(cerrors.InvalidArgument, "startIpAddress and endIpAddress must be valid IPv4 addresses") + } + + if bytes.Compare(s, e) > 0 { + return cerrors.New(cerrors.InvalidArgument, "startIpAddress must be less than or equal to endIpAddress") + } + + return nil +} + +// CreateOrUpdateFirewallRule creates or replaces a firewall rule on a cluster. +// +//nolint:gocritic // cfg matches the driver signature. +func (m *Mock) CreateOrUpdateFirewallRule(_ context.Context, cfg cpgdriver.CreateFirewallRuleConfig) (*cpgdriver.FirewallRule, error) { + if err := validName("firewall rule", cfg.Name); err != nil { + return nil, err + } + + if err := validateIPRange(cfg.StartIPAddress, cfg.EndIPAddress); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireClusterLocked(cfg.ResourceGroup, cfg.ClusterName); err != nil { + return nil, err + } + + fr := cpgdriver.FirewallRule{ + Name: cfg.Name, + ClusterName: cfg.ClusterName, + ResourceGroup: cfg.ResourceGroup, + ProvisioningState: cpgdriver.ProvisioningSucceeded, + StartIPAddress: cfg.StartIPAddress, + EndIPAddress: cfg.EndIPAddress, + } + m.firewallRules.Set(childKey(cfg.ResourceGroup, cfg.ClusterName, cfg.Name), fr) + + out := fr + + return &out, nil +} + +// GetFirewallRule returns a firewall rule by name. +func (m *Mock) GetFirewallRule(_ context.Context, rg, cluster, name string) (*cpgdriver.FirewallRule, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireClusterLocked(rg, cluster); err != nil { + return nil, err + } + + fr, ok := m.firewallRules.Get(childKey(rg, cluster, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "firewall rule %q not found", name) + } + + out := fr + + return &out, nil +} + +// ListFirewallRules returns the firewall rules of a cluster. +func (m *Mock) ListFirewallRules(_ context.Context, rg, cluster string) ([]cpgdriver.FirewallRule, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireClusterLocked(rg, cluster); err != nil { + return nil, err + } + + return listChildren(m.firewallRules, rg, cluster, firewallRuleKey, identity[cpgdriver.FirewallRule]), nil +} + +func firewallRuleKey(fr *cpgdriver.FirewallRule) string { + return childKey(fr.ResourceGroup, fr.ClusterName, fr.Name) +} + +func identity[T any](v *T) T { return *v } + +// DeleteFirewallRule removes a firewall rule. +func (m *Mock) DeleteFirewallRule(_ context.Context, rg, cluster, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireClusterLocked(rg, cluster); err != nil { + return err + } + + key := childKey(rg, cluster, name) + if !m.firewallRules.Has(key) { + return cerrors.Newf(cerrors.NotFound, "firewall rule %q not found", name) + } + + m.firewallRules.Delete(key) + + return nil +} diff --git a/providers/azure/cosmospostgresql/privateendpoints.go b/providers/azure/cosmospostgresql/privateendpoints.go new file mode 100644 index 00000000..df50fba7 --- /dev/null +++ b/providers/azure/cosmospostgresql/privateendpoints.go @@ -0,0 +1,157 @@ +package cosmospostgresql + +import ( + "context" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +func clonePEC(in *cpgdriver.PrivateEndpointConnection) cpgdriver.PrivateEndpointConnection { + pec := *in + pec.GroupIDs = cloneStrings(in.GroupIDs) + + return pec +} + +// CreateOrUpdatePrivateEndpointConnection creates or updates a private-endpoint +// connection (approving/rejecting the link). +func (m *Mock) CreateOrUpdatePrivateEndpointConnection( + _ context.Context, rg, cluster, name, status, description string, +) (*cpgdriver.PrivateEndpointConnection, error) { + if err := validName("private endpoint connection", name); err != nil { + return nil, err + } + + status = orDefault(status, "Approved") + if status != "Approved" && status != "Rejected" && status != "Pending" { + return nil, cerrors.Newf(cerrors.InvalidArgument, "invalid connection status %q", status) + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireClusterLocked(rg, cluster); err != nil { + return nil, err + } + + key := childKey(rg, cluster, name) + + pec := cpgdriver.PrivateEndpointConnection{ + Name: name, + ClusterName: cluster, + ResourceGroup: rg, + ProvisioningState: cpgdriver.ProvisioningSucceeded, + GroupIDs: []string{"coordinator"}, + PrivateEndpointID: m.clusterResourceID(rg, cluster) + "/privateEndpoints/" + name, + ConnectionStatus: status, + ConnectionDesc: description, + ActionsRequired: "None", + } + + if existing, ok := m.privateEPs.Get(key); ok { + pec.PrivateEndpointID = existing.PrivateEndpointID + } + + m.privateEPs.Set(key, pec) + + out := clonePEC(&pec) + + return &out, nil +} + +// GetPrivateEndpointConnection returns a connection by name. +func (m *Mock) GetPrivateEndpointConnection(_ context.Context, rg, cluster, name string) (*cpgdriver.PrivateEndpointConnection, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireClusterLocked(rg, cluster); err != nil { + return nil, err + } + + pec, ok := m.privateEPs.Get(childKey(rg, cluster, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "private endpoint connection %q not found", name) + } + + out := clonePEC(&pec) + + return &out, nil +} + +// ListPrivateEndpointConnections returns the connections of a cluster. +func (m *Mock) ListPrivateEndpointConnections(_ context.Context, rg, cluster string) ([]cpgdriver.PrivateEndpointConnection, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireClusterLocked(rg, cluster); err != nil { + return nil, err + } + + return listChildren(m.privateEPs, rg, cluster, pecKey, clonePEC), nil +} + +func pecKey(pec *cpgdriver.PrivateEndpointConnection) string { + return childKey(pec.ResourceGroup, pec.ClusterName, pec.Name) +} + +// DeletePrivateEndpointConnection removes a connection. +func (m *Mock) DeletePrivateEndpointConnection(_ context.Context, rg, cluster, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireClusterLocked(rg, cluster); err != nil { + return err + } + + key := childKey(rg, cluster, name) + if !m.privateEPs.Has(key) { + return cerrors.Newf(cerrors.NotFound, "private endpoint connection %q not found", name) + } + + m.privateEPs.Delete(key) + + return nil +} + +// GetPrivateLinkResource returns a private-link resource (group) of a cluster. +// The mock exposes a single "coordinator" group. +func (m *Mock) GetPrivateLinkResource(_ context.Context, rg, cluster, name string) (*cpgdriver.PrivateLinkResource, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.clusters.Has(clusterKey(rg, cluster)) { + return nil, cerrors.Newf(cerrors.NotFound, "cluster %q not found", cluster) + } + + if name != "coordinator" { + return nil, cerrors.Newf(cerrors.NotFound, "private link resource %q not found", name) + } + + plr := privateLinkResource(rg, cluster, name) + + return &plr, nil +} + +// ListPrivateLinkResources returns the private-link resources of a cluster. +func (m *Mock) ListPrivateLinkResources(_ context.Context, rg, cluster string) ([]cpgdriver.PrivateLinkResource, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.clusters.Has(clusterKey(rg, cluster)) { + return nil, cerrors.Newf(cerrors.NotFound, "cluster %q not found", cluster) + } + + return []cpgdriver.PrivateLinkResource{privateLinkResource(rg, cluster, "coordinator")}, nil +} + +func privateLinkResource(rg, cluster, name string) cpgdriver.PrivateLinkResource { + return cpgdriver.PrivateLinkResource{ + Name: name, + ClusterName: cluster, + ResourceGroup: rg, + GroupID: name, + RequiredMembers: []string{"coordinator"}, + RequiredZoneNames: []string{"privatelink.postgres.cosmos.azure.com"}, + } +} diff --git a/providers/azure/cosmospostgresql/roles.go b/providers/azure/cosmospostgresql/roles.go new file mode 100644 index 00000000..0b20d54c --- /dev/null +++ b/providers/azure/cosmospostgresql/roles.go @@ -0,0 +1,97 @@ +package cosmospostgresql + +import ( + "context" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +// CreateRole provisions a Postgres role on a cluster. +func (m *Mock) CreateRole(_ context.Context, cfg cpgdriver.CreateRoleConfig) (*cpgdriver.Role, error) { + if err := validName("role", cfg.Name); err != nil { + return nil, err + } + + if cfg.Password == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "role password is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireClusterLocked(cfg.ResourceGroup, cfg.ClusterName); err != nil { + return nil, err + } + + key := childKey(cfg.ResourceGroup, cfg.ClusterName, cfg.Name) + if m.roles.Has(key) { + return nil, cerrors.Newf(cerrors.AlreadyExists, "role %q already exists", cfg.Name) + } + + role := cpgdriver.Role{ + Name: cfg.Name, + ClusterName: cfg.ClusterName, + ResourceGroup: cfg.ResourceGroup, + ProvisioningState: cpgdriver.ProvisioningSucceeded, + } + m.roles.Set(key, role) + + out := role + + return &out, nil +} + +// GetRole returns a role by name. +func (m *Mock) GetRole(_ context.Context, rg, cluster, name string) (*cpgdriver.Role, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireClusterLocked(rg, cluster); err != nil { + return nil, err + } + + role, ok := m.roles.Get(childKey(rg, cluster, name)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "role %q not found", name) + } + + out := role + + return &out, nil +} + +// ListRoles returns the roles of a cluster. +func (m *Mock) ListRoles(_ context.Context, rg, cluster string) ([]cpgdriver.Role, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if err := m.requireClusterLocked(rg, cluster); err != nil { + return nil, err + } + + return listChildren(m.roles, rg, cluster, roleKey, identity[cpgdriver.Role]), nil +} + +func roleKey(role *cpgdriver.Role) string { + return childKey(role.ResourceGroup, role.ClusterName, role.Name) +} + +// DeleteRole removes a role. +func (m *Mock) DeleteRole(_ context.Context, rg, cluster, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.requireClusterLocked(rg, cluster); err != nil { + return err + } + + key := childKey(rg, cluster, name) + if !m.roles.Has(key) { + return cerrors.Newf(cerrors.NotFound, "role %q not found", name) + } + + m.roles.Delete(key) + + return nil +} diff --git a/providers/azure/cosmospostgresql/servers.go b/providers/azure/cosmospostgresql/servers.go new file mode 100644 index 00000000..2d96906e --- /dev/null +++ b/providers/azure/cosmospostgresql/servers.go @@ -0,0 +1,115 @@ +package cosmospostgresql + +import ( + "context" + "fmt" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +// serverName returns the derived node name: "-c" for the coordinator +// and "-w" for worker nodes. +func serverName(cluster, role string, idx int) string { + if role == cpgdriver.RoleCoordinator { + return cluster + "-c" + } + + return fmt.Sprintf("%s-w%d", cluster, idx) +} + +// nodesForCluster derives the (read-only) node list from a cluster's shape: one +// coordinator plus NodeCount workers. +func (m *Mock) nodesForCluster(c *cpgdriver.Cluster) []cpgdriver.Server { + // Clamp defensively: create/PATCH validate the bound, but a bad stored value + // must never reach make() with a negative/huge cap. + workers := c.NodeCount + if workers < 0 { + workers = 0 + } + + if workers > maxNodeCount { + workers = maxNodeCount + } + + out := make([]cpgdriver.Server, 0, workers+1) + + out = append(out, m.node(c, cpgdriver.RoleCoordinator, 0)) + for i := 0; i < workers; i++ { + out = append(out, m.node(c, cpgdriver.RoleWorker, i)) + } + + return out +} + +func (*Mock) node(c *cpgdriver.Cluster, role string, idx int) cpgdriver.Server { + name := serverName(c.Name, role, idx) + + vcores, edition := c.NodeVCores, c.NodeServerEdition + storage, public := c.NodeStorageQuotaInMb, c.NodeEnablePublicIPAccess + + if role == cpgdriver.RoleCoordinator { + vcores, edition = c.CoordinatorVCores, c.CoordinatorServerEdition + storage, public = c.CoordinatorStorageQuotaInMb, c.CoordinatorEnablePublicIPAccess + } + + haState := "" + if c.EnableHa { + haState = "Healthy" + } + + return cpgdriver.Server{ + Name: name, + ClusterName: c.Name, + ResourceGroup: c.ResourceGroup, + Role: role, + State: orDefault(c.State, "Ready"), + HaState: haState, + FullyQualifiedDomainName: name + "." + orDefault(c.Location, "eastus") + ".postgres.cosmos.azure.com", + AdministratorLogin: c.AdministratorLogin, + ServerEdition: edition, + VCores: vcores, + StorageQuotaInMb: storage, + CitusVersion: c.CitusVersion, + PostgresqlVersion: c.PostgresqlVersion, + EnableHa: c.EnableHa, + EnablePublicIPAccess: public, + // Every node of a read replica is read-only (coordinator included). + IsReadOnly: c.SourceResourceID != "", + } +} + +// GetServer returns a derived node by name. +func (m *Mock) GetServer(_ context.Context, rg, cluster, name string) (*cpgdriver.Server, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + c, ok := m.clusters.Get(clusterKey(rg, cluster)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "cluster %q not found", cluster) + } + + nodes := m.nodesForCluster(&c) + for i := range nodes { + if nodes[i].Name == name { + out := nodes[i] + + return &out, nil + } + } + + return nil, cerrors.Newf(cerrors.NotFound, "server %q not found", name) +} + +// ListServers returns the derived nodes of a cluster. +func (m *Mock) ListServers(_ context.Context, rg, cluster string) ([]cpgdriver.Server, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + c, ok := m.clusters.Get(clusterKey(rg, cluster)) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "cluster %q not found", cluster) + } + + return m.nodesForCluster(&c), nil +} diff --git a/providers/azure/databricks/arm_accessconnectors.go b/providers/azure/databricks/arm_accessconnectors.go new file mode 100644 index 00000000..06580290 --- /dev/null +++ b/providers/azure/databricks/arm_accessconnectors.go @@ -0,0 +1,208 @@ +package databricks + +import ( + "context" + "fmt" + "hash/fnv" + "sort" + "strings" + "time" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/databricks/driver" +) + +const accessConnectorsType = "accessConnectors" + +// emulatorTenantID is the single Azure AD directory (tenant) that all +// system-assigned identities in this emulator belong to. Real Azure has one +// tenant per directory, so this is a fixed emulator-wide value rather than a +// per-resource synthesized GUID. +const emulatorTenantID = "11111111-1111-1111-1111-111111111111" + +// CreateOrUpdateAccessConnector creates or updates an access connector, +// completing provisioning synchronously (store-and-echo). +func (m *Mock) CreateOrUpdateAccessConnector( + _ context.Context, cfg driver.AccessConnectorConfig, +) (*driver.AccessConnector, error) { + switch { + case cfg.Name == "": + return nil, errors.New(errors.InvalidArgument, "access connector name is required") + case cfg.ResourceGroup == "": + return nil, errors.New(errors.InvalidArgument, "resource group is required") + case cfg.Location == "": + return nil, errors.New(errors.InvalidArgument, "location is required") + } + + k := key(cfg.ResourceGroup, cfg.Name) + + if existing, ok := m.accessConnectors.Get(k); ok { + // ARM PUT is create-or-update: apply the mutable fields to a copy and + // swap it in, preserving identity fields (ID, created time). Location is + // immutable in real Azure, so it is left untouched. + updated := *existing + updated.Tags = copyMap(cfg.Tags) + updated.Identity = resolveIdentity(cfg.Identity, cfg.ResourceGroup, cfg.Name) + m.accessConnectors.Set(k, &updated) + + return cloneAccessConnector(&updated), nil + } + + ac := &driver.AccessConnector{ + ID: idgen.AzureID(m.opts.AccountID, cfg.ResourceGroup, providerNamespace, accessConnectorsType, cfg.Name), + Name: cfg.Name, + ResourceGroup: cfg.ResourceGroup, + Location: cfg.Location, + Tags: copyMap(cfg.Tags), + Identity: resolveIdentity(cfg.Identity, cfg.ResourceGroup, cfg.Name), + ProvisioningState: driver.StateSucceeded, + CreatedAt: m.opts.Clock.Now().UTC().Format(time.RFC3339), + } + + m.accessConnectors.Set(k, ac) + + return cloneAccessConnector(ac), nil +} + +// GetAccessConnector returns an access connector by resource group and name. +func (m *Mock) GetAccessConnector(_ context.Context, resourceGroup, name string) (*driver.AccessConnector, error) { + ac, ok := m.accessConnectors.Get(key(resourceGroup, name)) + if !ok { + return nil, errors.Newf(errors.NotFound, "access connector %q not found", name) + } + + return cloneAccessConnector(ac), nil +} + +// UpdateAccessConnector applies a PATCH (tags and/or identity) to an access +// connector. +func (m *Mock) UpdateAccessConnector( + _ context.Context, resourceGroup, name string, tags map[string]string, identity *driver.ManagedIdentity, +) (*driver.AccessConnector, error) { + k := key(resourceGroup, name) + + ac, ok := m.accessConnectors.Get(k) + if !ok { + return nil, errors.Newf(errors.NotFound, "access connector %q not found", name) + } + + // Mutate a copy and swap it in so concurrent readers never observe a torn + // update. A nil tags/identity leaves that field unchanged (PATCH semantics). + updated := *ac + if tags != nil { + updated.Tags = copyMap(tags) + } + + if identity != nil { + updated.Identity = resolveIdentity(identity, resourceGroup, name) + } + + m.accessConnectors.Set(k, &updated) + + return cloneAccessConnector(&updated), nil +} + +// DeleteAccessConnector deletes an access connector. +func (m *Mock) DeleteAccessConnector(_ context.Context, resourceGroup, name string) error { + if !m.accessConnectors.Delete(key(resourceGroup, name)) { + return errors.Newf(errors.NotFound, "access connector %q not found", name) + } + + return nil +} + +// ListAccessConnectorsByResourceGroup lists access connectors in a resource group. +func (m *Mock) ListAccessConnectorsByResourceGroup( + _ context.Context, resourceGroup string, +) ([]driver.AccessConnector, error) { + out := make([]driver.AccessConnector, 0) + + for _, ac := range m.accessConnectors.All() { + if ac.ResourceGroup == resourceGroup { + out = append(out, *cloneAccessConnector(ac)) + } + } + + sortAccessConnectors(out) + + return out, nil +} + +// ListAccessConnectors lists all access connectors in the subscription. +func (m *Mock) ListAccessConnectors(_ context.Context) ([]driver.AccessConnector, error) { + all := m.accessConnectors.All() + out := make([]driver.AccessConnector, 0, len(all)) + + for _, ac := range all { + out = append(out, *cloneAccessConnector(ac)) + } + + sortAccessConnectors(out) + + return out, nil +} + +func sortAccessConnectors(in []driver.AccessConnector) { + sort.SliceStable(in, func(i, j int) bool { return in[i].ID < in[j].ID }) +} + +// resolveIdentity normalizes an incoming managed identity: for a system-assigned +// identity it synthesizes deterministic principal/tenant GUIDs (as Azure does on +// assignment); a nil or "None" identity resolves to nil. +func resolveIdentity(in *driver.ManagedIdentity, resourceGroup, name string) *driver.ManagedIdentity { + if in == nil || in.Type == "" || strings.EqualFold(in.Type, "None") { + return nil + } + + out := &driver.ManagedIdentity{ + Type: in.Type, + UserAssigned: append([]string(nil), in.UserAssigned...), + } + + if strings.Contains(strings.ToLower(in.Type), "systemassigned") { + // PrincipalID is per-resource: keying on (resource group, name) means two + // connectors with the same name in different RGs get distinct principals, + // while the value stays stable across gets/restarts for the same resource. + out.PrincipalID = synthGUID("principal/" + resourceGroup + "/" + name) + // TenantID is the emulator's single directory (one tenant per directory). + out.TenantID = emulatorTenantID + } + + return out +} + +func cloneAccessConnector(ac *driver.AccessConnector) *driver.AccessConnector { + clone := *ac + clone.Tags = copyMap(ac.Tags) + + if ac.Identity != nil { + id := *ac.Identity + id.UserAssigned = append([]string(nil), ac.Identity.UserAssigned...) + clone.Identity = &id + } + + return &clone +} + +// guidNodeMask isolates the low 48 bits used as a GUID's final node segment. +const guidNodeMask = 0xffffffffffff + +// synthGUID derives a deterministic GUID-shaped string from s, used for +// synthesized identity principal/tenant IDs. +func synthGUID(s string) string { + h1 := fnv.New64a() + _, _ = h1.Write([]byte(s)) + a := h1.Sum64() + + h2 := fnv.New64a() + _, _ = h2.Write([]byte(s + "#salt")) + b := h2.Sum64() + + // Deliberate truncation + bit-shifting to assemble a GUID-shaped string from + // hash bits; the value is synthetic, not a real security identifier, and the + // shift widths are the fixed GUID field boundaries. + //nolint:gosec,mnd // intentional narrowing + GUID field-width shifts + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + uint32(a>>32), uint16(a>>16), uint16(a), uint16(b>>48), b&guidNodeMask) +} diff --git a/providers/azure/databricks/arm_network.go b/providers/azure/databricks/arm_network.go new file mode 100644 index 00000000..88f752d2 --- /dev/null +++ b/providers/azure/databricks/arm_network.go @@ -0,0 +1,324 @@ +package databricks + +import ( + "context" + "sort" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/databricks/driver" +) + +// Workspace sub-resource collection names. +const ( + pecType = "privateEndpointConnections" + plrType = "privateLinkResources" + peeringType = "virtualNetworkPeerings" + outboundType = "outboundNetworkDependenciesEndpoints" +) + +// requireWorkspace returns NotFound when the parent workspace is absent. The +// workspace sub-resources (PEC, private link, peering, outbound) are all scoped +// under a workspace and must reject requests for a missing one. +func (m *Mock) requireWorkspace(resourceGroup, workspace string) error { + if !m.workspaces.Has(key(resourceGroup, workspace)) { + return errors.Newf(errors.NotFound, "workspace %q not found", workspace) + } + + return nil +} + +// subKey builds a store key scoped under a workspace: rg/workspace/type/name. +func subKey(resourceGroup, workspace, childType, name string) string { + return key(resourceGroup, workspace) + "/" + childType + "/" + name +} + +// subID builds the ARM ID for a workspace sub-resource. +func (m *Mock) subID(resourceGroup, workspace, childType, name string) string { + return idgen.AzureID(m.opts.AccountID, resourceGroup, providerNamespace, resourceType, workspace) + + "/" + childType + "/" + name +} + +// --- Private endpoint connections --- + +// PutPrivateEndpointConnection creates or updates a private-endpoint connection +// on a workspace (store-and-echo of the approval state). +func (m *Mock) PutPrivateEndpointConnection( + _ context.Context, resourceGroup, workspace, name, status, description string, +) (*driver.PrivateEndpointConnection, error) { + if err := m.requireWorkspace(resourceGroup, workspace); err != nil { + return nil, err + } + + if name == "" { + return nil, errors.New(errors.InvalidArgument, "private endpoint connection name is required") + } + + if status == "" { + status = "Approved" + } + + k := subKey(resourceGroup, workspace, pecType, name) + + c := &driver.PrivateEndpointConnection{ + ID: m.subID(resourceGroup, workspace, pecType, name), + Name: name, + GroupIDs: []string{groupUIAPI}, + Status: status, + Description: description, + ProvisioningState: driver.StateSucceeded, + } + + // Preserve a previously recorded private-endpoint reference across updates. + if existing, ok := m.privateEndpoints.Get(k); ok { + c.PrivateEndpointID = existing.PrivateEndpointID + } + + m.privateEndpoints.Set(k, c) + + return clonePEC(c), nil +} + +// GetPrivateEndpointConnection returns a private-endpoint connection by name. +func (m *Mock) GetPrivateEndpointConnection( + _ context.Context, resourceGroup, workspace, name string, +) (*driver.PrivateEndpointConnection, error) { + c, ok := m.privateEndpoints.Get(subKey(resourceGroup, workspace, pecType, name)) + if !ok { + return nil, errors.Newf(errors.NotFound, "private endpoint connection %q not found", name) + } + + return clonePEC(c), nil +} + +// DeletePrivateEndpointConnection removes a private-endpoint connection. +func (m *Mock) DeletePrivateEndpointConnection(_ context.Context, resourceGroup, workspace, name string) error { + if !m.privateEndpoints.Delete(subKey(resourceGroup, workspace, pecType, name)) { + return errors.Newf(errors.NotFound, "private endpoint connection %q not found", name) + } + + return nil +} + +// ListPrivateEndpointConnections lists a workspace's private-endpoint connections. +// +//nolint:dupl // parallel per-collection prefix scan; mirrors ListVNetPeerings over a different store/type +func (m *Mock) ListPrivateEndpointConnections( + _ context.Context, resourceGroup, workspace string, +) ([]driver.PrivateEndpointConnection, error) { + if err := m.requireWorkspace(resourceGroup, workspace); err != nil { + return nil, err + } + + prefix := key(resourceGroup, workspace) + "/" + pecType + "/" + out := make([]driver.PrivateEndpointConnection, 0) + + for k, c := range m.privateEndpoints.All() { + if len(k) >= len(prefix) && k[:len(prefix)] == prefix { + out = append(out, *clonePEC(c)) + } + } + + sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + + return out, nil +} + +func clonePEC(c *driver.PrivateEndpointConnection) *driver.PrivateEndpointConnection { + clone := *c + clone.GroupIDs = append([]string(nil), c.GroupIDs...) + + return &clone +} + +// --- Private link resources (synthesized, per workspace) --- + +// Databricks workspace private-link group IDs. +const ( + groupUIAPI = "databricks_ui_api" + groupAuth = "browser_authentication" +) + +// GetPrivateLinkResource returns the private-link resource for a group id. +func (m *Mock) GetPrivateLinkResource( + _ context.Context, resourceGroup, workspace, groupID string, +) (*driver.GroupIDInformation, error) { + if err := m.requireWorkspace(resourceGroup, workspace); err != nil { + return nil, err + } + + all := m.privateLinkResources(resourceGroup, workspace) + for i := range all { + if all[i].GroupID == groupID || all[i].Name == groupID { + out := all[i] + + return &out, nil + } + } + + return nil, errors.Newf(errors.NotFound, "private link resource %q not found", groupID) +} + +// ListPrivateLinkResources lists a workspace's private-link resources. +func (m *Mock) ListPrivateLinkResources( + _ context.Context, resourceGroup, workspace string, +) ([]driver.GroupIDInformation, error) { + if err := m.requireWorkspace(resourceGroup, workspace); err != nil { + return nil, err + } + + return m.privateLinkResources(resourceGroup, workspace), nil +} + +// privateLinkResources returns the synthesized private-link group set for a +// workspace: the two group IDs a real Databricks workspace exposes. +func (m *Mock) privateLinkResources(resourceGroup, workspace string) []driver.GroupIDInformation { + return []driver.GroupIDInformation{ + { + ID: m.subID(resourceGroup, workspace, plrType, groupUIAPI), + Name: groupUIAPI, + GroupID: groupUIAPI, + RequiredMembers: []string{"databricks_ui_api"}, + RequiredZoneNames: []string{"privatelink.azuredatabricks.net"}, + }, + { + ID: m.subID(resourceGroup, workspace, plrType, groupAuth), + Name: groupAuth, + GroupID: groupAuth, + RequiredMembers: []string{"browser_authentication"}, + RequiredZoneNames: []string{"privatelink.azuredatabricks.net"}, + }, + } +} + +// --- Virtual network peerings --- + +// CreateOrUpdateVNetPeering creates or updates a workspace VNet peering +// (store-and-echo; peering springs to Connected/Succeeded synchronously). +func (m *Mock) CreateOrUpdateVNetPeering( + _ context.Context, resourceGroup, workspace, name string, cfg driver.VirtualNetworkPeeringConfig, +) (*driver.VirtualNetworkPeering, error) { + if err := m.requireWorkspace(resourceGroup, workspace); err != nil { + return nil, err + } + + if name == "" { + return nil, errors.New(errors.InvalidArgument, "peering name is required") + } + + p := &driver.VirtualNetworkPeering{ + ID: m.subID(resourceGroup, workspace, peeringType, name), + Name: name, + AllowForwardedTraffic: cfg.AllowForwardedTraffic, + AllowGatewayTransit: cfg.AllowGatewayTransit, + AllowVirtualNetworkAccess: cfg.AllowVirtualNetworkAccess, + UseRemoteGateways: cfg.UseRemoteGateways, + DatabricksVNetID: cfg.DatabricksVNetID, + DatabricksAddressSpace: cloneAddressSpace(cfg.DatabricksAddressSpace), + RemoteVNetID: cfg.RemoteVNetID, + RemoteAddressSpace: cloneAddressSpace(cfg.RemoteAddressSpace), + PeeringState: driver.PeeringStateConnected, + ProvisioningState: driver.StateSucceeded, + } + + m.vnetPeerings.Set(subKey(resourceGroup, workspace, peeringType, name), p) + + return clonePeering(p), nil +} + +// GetVNetPeering returns a workspace VNet peering by name. +func (m *Mock) GetVNetPeering( + _ context.Context, resourceGroup, workspace, name string, +) (*driver.VirtualNetworkPeering, error) { + p, ok := m.vnetPeerings.Get(subKey(resourceGroup, workspace, peeringType, name)) + if !ok { + return nil, errors.Newf(errors.NotFound, "virtual network peering %q not found", name) + } + + return clonePeering(p), nil +} + +// DeleteVNetPeering removes a workspace VNet peering. +func (m *Mock) DeleteVNetPeering(_ context.Context, resourceGroup, workspace, name string) error { + if !m.vnetPeerings.Delete(subKey(resourceGroup, workspace, peeringType, name)) { + return errors.Newf(errors.NotFound, "virtual network peering %q not found", name) + } + + return nil +} + +// ListVNetPeerings lists a workspace's VNet peerings. +// +//nolint:dupl // parallel per-collection prefix scan; mirrors ListPrivateEndpointConnections over a different store/type +func (m *Mock) ListVNetPeerings( + _ context.Context, resourceGroup, workspace string, +) ([]driver.VirtualNetworkPeering, error) { + if err := m.requireWorkspace(resourceGroup, workspace); err != nil { + return nil, err + } + + prefix := key(resourceGroup, workspace) + "/" + peeringType + "/" + out := make([]driver.VirtualNetworkPeering, 0) + + for k, p := range m.vnetPeerings.All() { + if len(k) >= len(prefix) && k[:len(prefix)] == prefix { + out = append(out, *clonePeering(p)) + } + } + + sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + + return out, nil +} + +func clonePeering(p *driver.VirtualNetworkPeering) *driver.VirtualNetworkPeering { + clone := *p + clone.DatabricksAddressSpace = cloneAddressSpace(p.DatabricksAddressSpace) + clone.RemoteAddressSpace = cloneAddressSpace(p.RemoteAddressSpace) + + return &clone +} + +func cloneAddressSpace(in *driver.AddressSpace) *driver.AddressSpace { + if in == nil { + return nil + } + + return &driver.AddressSpace{AddressPrefixes: append([]string(nil), in.AddressPrefixes...)} +} + +// --- Outbound network dependencies (synthesized, per workspace) --- + +// ListOutboundNetworkDependencies returns the synthesized outbound network +// dependency endpoints a workspace reaches. Store-and-echo: the domains mirror +// the real control-plane categories; live reachability is not probed. +func (m *Mock) ListOutboundNetworkDependencies( + _ context.Context, resourceGroup, workspace string, +) ([]driver.OutboundEndpoint, error) { + if err := m.requireWorkspace(resourceGroup, workspace); err != nil { + return nil, err + } + + https := []driver.EndpointDetail{{Port: 443}} + + return []driver.OutboundEndpoint{ + { + Category: "control-plane", + Endpoints: []driver.EndpointDependency{ + {DomainName: "cp.azuredatabricks.net", EndpointDetails: https}, + }, + }, + { + Category: "azure-storage", + Endpoints: []driver.EndpointDependency{ + {DomainName: "dbstorage.blob.core.windows.net", EndpointDetails: https}, + }, + }, + { + Category: "azure-eventhub", + Endpoints: []driver.EndpointDependency{ + {DomainName: "prod.servicebus.windows.net", EndpointDetails: https}, + }, + }, + }, nil +} diff --git a/providers/azure/databricks/arm_operations.go b/providers/azure/databricks/arm_operations.go new file mode 100644 index 00000000..875b5d64 --- /dev/null +++ b/providers/azure/databricks/arm_operations.go @@ -0,0 +1,49 @@ +package databricks + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/services/databricks/driver" +) + +// ListOperations returns the Microsoft.Databricks provider operations list. It +// is a static catalog of the RBAC operations the provider exposes, mirroring +// what the real armdatabricks OperationsClient returns. +func (*Mock) ListOperations(_ context.Context) ([]driver.Operation, error) { + return append([]driver.Operation(nil), databricksOperations...), nil +} + +// databricksOperations is the provider operation catalog. Kept deliberately +// representative (the real list is longer); each entry round-trips the display +// metadata the SDK surfaces. +// +//nolint:gochecknoglobals // immutable static catalog +var databricksOperations = []driver.Operation{ + op("workspaces/read", "workspaces", "Read", "Get a workspace"), + op("workspaces/write", "workspaces", "Write", "Create or update a workspace"), + op("workspaces/delete", "workspaces", "Delete", "Delete a workspace"), + op("accessConnectors/read", "accessConnectors", "Read", "Get an access connector"), + op("accessConnectors/write", "accessConnectors", "Write", "Create or update an access connector"), + op("accessConnectors/delete", "accessConnectors", "Delete", "Delete an access connector"), + op("workspaces/privateEndpointConnections/read", "privateEndpointConnections", "Read", "Get a private endpoint connection"), + op("workspaces/privateEndpointConnections/write", "privateEndpointConnections", + "Write", "Approve or reject a private endpoint connection"), + op("workspaces/privateEndpointConnections/delete", "privateEndpointConnections", "Delete", "Delete a private endpoint connection"), + op("workspaces/privateLinkResources/read", "privateLinkResources", "Read", "Get workspace private link resources"), + op("workspaces/virtualNetworkPeerings/read", "virtualNetworkPeerings", "Read", "Get a virtual network peering"), + op("workspaces/virtualNetworkPeerings/write", "virtualNetworkPeerings", "Write", "Create or update a virtual network peering"), + op("workspaces/virtualNetworkPeerings/delete", "virtualNetworkPeerings", "Delete", "Delete a virtual network peering"), + op("workspaces/outboundNetworkDependenciesEndpoints/read", "outboundNetworkDependenciesEndpoints", + "Read", "List workspace outbound network dependencies"), + op("operations/read", "operations", "Read", "List Microsoft.Databricks operations"), +} + +func op(name, resource, verb, description string) driver.Operation { + return driver.Operation{ + Name: providerNamespace + "/" + name, + Provider: providerNamespace, + Resource: resource, + Operation: verb, + Description: description, + } +} diff --git a/providers/azure/databricks/arm_resources_test.go b/providers/azure/databricks/arm_resources_test.go new file mode 100644 index 00000000..733f5c2e --- /dev/null +++ b/providers/azure/databricks/arm_resources_test.go @@ -0,0 +1,301 @@ +package databricks + +import ( + "context" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/databricks/driver" +) + +func newMock(t *testing.T) *Mock { + t.Helper() + + return New(config.NewOptions()) +} + +func seedWS(t *testing.T, m *Mock, rg, name string) { + t.Helper() + + _, err := m.CreateWorkspace(context.Background(), driver.WorkspaceConfig{ + Name: name, ResourceGroup: rg, Location: "eastus", ManagedResourceGroupID: "/subscriptions/s/resourceGroups/managed", + }) + if err != nil { + t.Fatalf("seed workspace: %v", err) + } +} + +// --- Access connectors --- + +func TestAccessConnectorLifecycle(t *testing.T) { + m := newMock(t) + ctx := context.Background() + + ac, err := m.CreateOrUpdateAccessConnector(ctx, driver.AccessConnectorConfig{ + Name: "ac1", ResourceGroup: "rg", Location: "eastus", + Tags: map[string]string{"env": "test"}, + Identity: &driver.ManagedIdentity{Type: "SystemAssigned"}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + if ac.ProvisioningState != driver.StateSucceeded { + t.Fatalf("provisioning = %q", ac.ProvisioningState) + } + + if ac.Identity == nil || ac.Identity.PrincipalID == "" || ac.Identity.TenantID == "" { + t.Fatalf("system-assigned identity should synthesize principal/tenant, got %+v", ac.Identity) + } + + // Deterministic synthesis: same name → same GUIDs on re-create. + principal := ac.Identity.PrincipalID + + got, err := m.GetAccessConnector(ctx, "rg", "ac1") + if err != nil { + t.Fatalf("get: %v", err) + } + + if got.Identity.PrincipalID != principal { + t.Fatalf("principal not stable: %q vs %q", got.Identity.PrincipalID, principal) + } + + // PATCH tags only; identity nil leaves identity unchanged. + upd, err := m.UpdateAccessConnector(ctx, "rg", "ac1", map[string]string{"env": "prod"}, nil) + if err != nil { + t.Fatalf("update: %v", err) + } + + if upd.Tags["env"] != "prod" || upd.Identity == nil { + t.Fatalf("patch = %+v", upd) + } + + if err := m.DeleteAccessConnector(ctx, "rg", "ac1"); err != nil { + t.Fatalf("delete: %v", err) + } + + if _, err := m.GetAccessConnector(ctx, "rg", "ac1"); !errors.IsNotFound(err) { + t.Fatalf("get after delete = %v, want NotFound", err) + } +} + +func TestAccessConnectorValidationAndListing(t *testing.T) { + m := newMock(t) + ctx := context.Background() + + if _, err := m.CreateOrUpdateAccessConnector(ctx, driver.AccessConnectorConfig{Name: "x", ResourceGroup: "rg"}); !errors.IsInvalidArgument(err) { + t.Fatalf("empty location = %v, want InvalidArgument", err) + } + + mk := func(rg, n string) { + if _, err := m.CreateOrUpdateAccessConnector(ctx, driver.AccessConnectorConfig{Name: n, ResourceGroup: rg, Location: "eastus"}); err != nil { + t.Fatalf("create %s/%s: %v", rg, n, err) + } + } + mk("rg1", "a") + mk("rg1", "b") + mk("rg2", "c") + + byRG, _ := m.ListAccessConnectorsByResourceGroup(ctx, "rg1") + if len(byRG) != 2 { + t.Fatalf("list rg1 = %d, want 2", len(byRG)) + } + + all, _ := m.ListAccessConnectors(ctx) + if len(all) != 3 { + t.Fatalf("list all = %d, want 3", len(all)) + } + + // "None" identity resolves to nil. + ac, _ := m.CreateOrUpdateAccessConnector(ctx, driver.AccessConnectorConfig{ + Name: "none", ResourceGroup: "rg1", Location: "eastus", Identity: &driver.ManagedIdentity{Type: "None"}, + }) + if ac.Identity != nil { + t.Fatalf("None identity should be nil, got %+v", ac.Identity) + } +} + +// --- Private endpoint connections --- + +func TestPrivateEndpointConnectionLifecycle(t *testing.T) { + m := newMock(t) + ctx := context.Background() + seedWS(t, m, "rg", "ws") + + c, err := m.PutPrivateEndpointConnection(ctx, "rg", "ws", "pec1", "", "hi") + if err != nil { + t.Fatalf("put: %v", err) + } + + if c.Status != "Approved" || c.ProvisioningState != driver.StateSucceeded { + t.Fatalf("pec = %+v", c) + } + + if len(c.GroupIDs) == 0 || c.GroupIDs[0] != groupUIAPI { + t.Fatalf("groupIDs = %v", c.GroupIDs) + } + + list, _ := m.ListPrivateEndpointConnections(ctx, "rg", "ws") + if len(list) != 1 { + t.Fatalf("list = %d, want 1", len(list)) + } + + if err := m.DeletePrivateEndpointConnection(ctx, "rg", "ws", "pec1"); err != nil { + t.Fatalf("delete: %v", err) + } + + if _, err := m.GetPrivateEndpointConnection(ctx, "rg", "ws", "pec1"); !errors.IsNotFound(err) { + t.Fatalf("get after delete = %v", err) + } +} + +func TestPrivateEndpointConnectionMissingWorkspace(t *testing.T) { + m := newMock(t) + ctx := context.Background() + + if _, err := m.PutPrivateEndpointConnection(ctx, "rg", "ghost", "pec", "Approved", ""); !errors.IsNotFound(err) { + t.Fatalf("put on missing workspace = %v, want NotFound", err) + } + + if _, err := m.ListPrivateEndpointConnections(ctx, "rg", "ghost"); !errors.IsNotFound(err) { + t.Fatalf("list on missing workspace = %v, want NotFound", err) + } +} + +// --- Private link resources --- + +func TestPrivateLinkResources(t *testing.T) { + m := newMock(t) + ctx := context.Background() + seedWS(t, m, "rg", "ws") + + list, err := m.ListPrivateLinkResources(ctx, "rg", "ws") + if err != nil { + t.Fatalf("list: %v", err) + } + + if len(list) != 2 { + t.Fatalf("want 2 private link resources, got %d", len(list)) + } + + g, err := m.GetPrivateLinkResource(ctx, "rg", "ws", groupUIAPI) + if err != nil { + t.Fatalf("get: %v", err) + } + + if g.GroupID != groupUIAPI || len(g.RequiredZoneNames) == 0 { + t.Fatalf("plr = %+v", g) + } + + if _, err := m.GetPrivateLinkResource(ctx, "rg", "ws", "nope"); !errors.IsNotFound(err) { + t.Fatalf("get unknown = %v, want NotFound", err) + } + + if _, err := m.ListPrivateLinkResources(ctx, "rg", "ghost"); !errors.IsNotFound(err) { + t.Fatalf("list on missing workspace = %v, want NotFound", err) + } +} + +// --- VNet peering --- + +func TestVNetPeeringLifecycle(t *testing.T) { + m := newMock(t) + ctx := context.Background() + seedWS(t, m, "rg", "ws") + + p, err := m.CreateOrUpdateVNetPeering(ctx, "rg", "ws", "peer1", driver.VirtualNetworkPeeringConfig{ + AllowVirtualNetworkAccess: true, + RemoteVNetID: "/subscriptions/s/rg/x/providers/Microsoft.Network/virtualNetworks/remote", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + if p.PeeringState != driver.PeeringStateConnected || p.ProvisioningState != driver.StateSucceeded { + t.Fatalf("peering state = %+v", p) + } + + if !p.AllowVirtualNetworkAccess || p.RemoteVNetID == "" { + t.Fatalf("peering fields not echoed: %+v", p) + } + + list, _ := m.ListVNetPeerings(ctx, "rg", "ws") + if len(list) != 1 { + t.Fatalf("list = %d, want 1", len(list)) + } + + if err := m.DeleteVNetPeering(ctx, "rg", "ws", "peer1"); err != nil { + t.Fatalf("delete: %v", err) + } + + if _, err := m.GetVNetPeering(ctx, "rg", "ws", "peer1"); !errors.IsNotFound(err) { + t.Fatalf("get after delete = %v", err) + } +} + +func TestVNetPeeringMissingWorkspace(t *testing.T) { + m := newMock(t) + ctx := context.Background() + + _, err := m.CreateOrUpdateVNetPeering(ctx, "rg", "ghost", "peer", driver.VirtualNetworkPeeringConfig{}) + if !errors.IsNotFound(err) { + t.Fatalf("create on missing workspace = %v, want NotFound", err) + } +} + +// --- Outbound & operations --- + +func TestOutboundNetworkDependencies(t *testing.T) { + m := newMock(t) + ctx := context.Background() + seedWS(t, m, "rg", "ws") + + eps, err := m.ListOutboundNetworkDependencies(ctx, "rg", "ws") + if err != nil { + t.Fatalf("list: %v", err) + } + + if len(eps) == 0 { + t.Fatal("want at least one outbound category") + } + + for _, e := range eps { + if e.Category == "" || len(e.Endpoints) == 0 || e.Endpoints[0].DomainName == "" { + t.Fatalf("malformed outbound endpoint: %+v", e) + } + } + + if _, err := m.ListOutboundNetworkDependencies(ctx, "rg", "ghost"); !errors.IsNotFound(err) { + t.Fatalf("list on missing workspace = %v, want NotFound", err) + } +} + +func TestListOperations(t *testing.T) { + m := newMock(t) + + ops, err := m.ListOperations(context.Background()) + if err != nil { + t.Fatalf("list: %v", err) + } + + if len(ops) == 0 { + t.Fatal("want a non-empty operations catalog") + } + + found := false + + for _, o := range ops { + if o.Provider != providerNamespace { + t.Fatalf("op provider = %q, want %q", o.Provider, providerNamespace) + } + + if o.Name == providerNamespace+"/workspaces/read" { + found = true + } + } + + if !found { + t.Fatal("expected workspaces/read in the operations catalog") + } +} diff --git a/providers/azure/databricks/databricks.go b/providers/azure/databricks/databricks.go index 51ccea7d..2496af54 100644 --- a/providers/azure/databricks/databricks.go +++ b/providers/azure/databricks/databricks.go @@ -43,7 +43,13 @@ type Mock struct { policies *memstore.Store[*driver.ClusterPolicy] libraries *memstore.Store[[]driver.LibraryStatus] permissions *memstore.Store[*driver.ObjectPermissions] - opts *config.Options + + // Extended ARM control-plane resources (issue #209). + accessConnectors *memstore.Store[*driver.AccessConnector] + privateEndpoints *memstore.Store[*driver.PrivateEndpointConnection] + vnetPeerings *memstore.Store[*driver.VirtualNetworkPeering] + + opts *config.Options jobSeq atomic.Int64 runSeq atomic.Int64 @@ -60,7 +66,12 @@ func New(opts *config.Options) *Mock { policies: memstore.New[*driver.ClusterPolicy](), libraries: memstore.New[[]driver.LibraryStatus](), permissions: memstore.New[*driver.ObjectPermissions](), - opts: opts, + + accessConnectors: memstore.New[*driver.AccessConnector](), + privateEndpoints: memstore.New[*driver.PrivateEndpointConnection](), + vnetPeerings: memstore.New[*driver.VirtualNetworkPeering](), + + opts: opts, } } diff --git a/providers/azure/functions/appserviceplan.go b/providers/azure/functions/appserviceplan.go new file mode 100644 index 00000000..b047361f --- /dev/null +++ b/providers/azure/functions/appserviceplan.go @@ -0,0 +1,72 @@ +package functions + +import ( + "context" + "fmt" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// AppServicePlan is an Azure App Service plan (Microsoft.Web/serverfarms) — the +// resource that carries the pricing tier an App Service or Function App bills +// on. Only the cost-relevant SKU is modeled. +type AppServicePlan struct { + Name string + ID string + Location string + SKUName string // F1 / B1 / S1 / P1v3 / Y1 (Consumption) / EP1 (Elastic Premium) + SKUTier string // Free / Basic / Standard / PremiumV3 / Dynamic / ElasticPremium + Kind string // app / functionapp / linux + Capacity int + Tags map[string]string +} + +// CreateAppServicePlan stores a plan, defaulting the fields real Azure fills in. +// +//nolint:gocritic // p is a value seed matching the CreateScaleSet convention. +func (m *Mock) CreateAppServicePlan(_ context.Context, p AppServicePlan) (*AppServicePlan, error) { + if p.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "app service plan name is required") + } + + if p.ID == "" { + p.ID = fmt.Sprintf( + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/serverfarms/%s", p.Name) + } + + if p.Location == "" { + p.Location = m.opts.Region + } + + if p.SKUName == "" { + p.SKUName = "Y1" + } + + if p.SKUTier == "" { + p.SKUTier = "Dynamic" + } + + if p.Capacity == 0 { + p.Capacity = 1 + } + + stored := p + + m.plans.Set(p.Name, &stored) + + out := stored + + return &out, nil +} + +// ListAppServicePlans returns every stored App Service plan. +func (m *Mock) ListAppServicePlans(_ context.Context) ([]AppServicePlan, error) { + stored := m.plans.SortedValues() + + out := make([]AppServicePlan, 0, len(stored)) + for _, p := range stored { + out = append(out, *p) + } + + return out, nil +} diff --git a/providers/azure/functions/appserviceplan_test.go b/providers/azure/functions/appserviceplan_test.go new file mode 100644 index 00000000..15d83f0a --- /dev/null +++ b/providers/azure/functions/appserviceplan_test.go @@ -0,0 +1,72 @@ +package functions + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateAppServicePlanDefaults(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + plan, err := m.CreateAppServicePlan(ctx, AppServicePlan{Name: "consumption"}) + require.NoError(t, err) + + // Real Azure fills in the Consumption defaults when the SKU is omitted. + assert.Equal(t, "consumption", plan.Name) + assert.Equal(t, "Y1", plan.SKUName) + assert.Equal(t, "Dynamic", plan.SKUTier) + assert.Equal(t, 1, plan.Capacity) + assert.NotEmpty(t, plan.ID) + assert.Contains(t, plan.ID, "Microsoft.Web/serverfarms/consumption") + assert.NotEmpty(t, plan.Location) +} + +func TestCreateAppServicePlanEmptyName(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + _, err := m.CreateAppServicePlan(ctx, AppServicePlan{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "name is required") +} + +func TestListAppServicePlansRoundTrip(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + t.Run("empty", func(t *testing.T) { + plans, err := m.ListAppServicePlans(ctx) + require.NoError(t, err) + assert.Empty(t, plans) + }) + + t.Run("explicit values round-trip", func(t *testing.T) { + _, err := m.CreateAppServicePlan(ctx, AppServicePlan{ + Name: "premium", + Location: "westus2", + SKUName: "P1v3", + SKUTier: "PremiumV3", + Kind: "linux", + Capacity: 3, + Tags: map[string]string{"env": "prod"}, + }) + require.NoError(t, err) + + plans, err := m.ListAppServicePlans(ctx) + require.NoError(t, err) + require.Len(t, plans, 1) + + got := plans[0] + assert.Equal(t, "premium", got.Name) + assert.Equal(t, "westus2", got.Location) + assert.Equal(t, "P1v3", got.SKUName) + assert.Equal(t, "PremiumV3", got.SKUTier) + assert.Equal(t, "linux", got.Kind) + assert.Equal(t, 3, got.Capacity) + assert.Equal(t, "prod", got.Tags["env"]) + }) +} diff --git a/providers/azure/functions/functions.go b/providers/azure/functions/functions.go index 95b87e34..079f468a 100644 --- a/providers/azure/functions/functions.go +++ b/providers/azure/functions/functions.go @@ -60,6 +60,7 @@ type Mock struct { funcs *memstore.Store[funcData] layers *memstore.Store[*layerData] mappings *memstore.Store[*driver.EventSourceMappingInfo] + plans *memstore.Store[*AppServicePlan] opts *config.Options handlersMu sync.RWMutex handlers map[string]driver.HandlerFunc @@ -100,6 +101,7 @@ func New(opts *config.Options) *Mock { funcs: memstore.New[funcData](), layers: memstore.New[*layerData](), mappings: memstore.New[*driver.EventSourceMappingInfo](), + plans: memstore.New[*AppServicePlan](), opts: opts, handlers: make(map[string]driver.HandlerFunc), } @@ -208,7 +210,20 @@ func (m *Mock) Invoke(ctx context.Context, input driver.InvokeInput) (*driver.In } if h == nil { - return &driver.InvokeOutput{StatusCode: 500, Error: "no handler registered"}, nil + // The emulator can't execute uploaded function code, so with no Go + // handler registered we return a successful stub echoing the request + // payload rather than a FunctionError — mirroring the AWS Lambda + // provider so identical cross-provider tests behave the same. + m.emitMetric(input.FunctionName, map[string]float64{ + "FunctionExecutionCount": 1, "FunctionExecutionUnits": 1, + }) + + payload := input.Payload + if len(payload) == 0 { + payload = []byte("{}") + } + + return &driver.InvokeOutput{StatusCode: 200, Payload: payload}, nil } payload, err := h(ctx, input.Payload) diff --git a/providers/azure/functions/functions_test.go b/providers/azure/functions/functions_test.go index fd1147c7..e6f362ce 100644 --- a/providers/azure/functions/functions_test.go +++ b/providers/azure/functions/functions_test.go @@ -175,11 +175,14 @@ func TestInvokeFunction(t *testing.T) { _, err := m.CreateFunction(ctx, driver.FunctionConfig{Name: "fn1", Runtime: "go1.x"}) require.NoError(t, err) - t.Run("no handler registered", func(t *testing.T) { - out, err := m.Invoke(ctx, driver.InvokeInput{FunctionName: "fn1", Payload: []byte("test")}) + t.Run("no handler echoes a success stub", func(t *testing.T) { + // Mirrors AWS Lambda (#319 review): no Go handler → 200 + echoed + // payload, not a FunctionError, so cross-provider tests match. + out, err := m.Invoke(ctx, driver.InvokeInput{FunctionName: "fn1", Payload: []byte(`{"k":1}`)}) require.NoError(t, err) - assert.Equal(t, 500, out.StatusCode) - assert.Equal(t, "no handler registered", out.Error) + assert.Equal(t, 200, out.StatusCode) + assert.Equal(t, "", out.Error) + assert.Equal(t, `{"k":1}`, string(out.Payload)) }) t.Run("with handler success", func(t *testing.T) { diff --git a/providers/azure/virtualmachines/scaleset.go b/providers/azure/virtualmachines/scaleset.go new file mode 100644 index 00000000..764cc484 --- /dev/null +++ b/providers/azure/virtualmachines/scaleset.go @@ -0,0 +1,80 @@ +package virtualmachines + +import ( + "context" + "fmt" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// ScaleSet is an Azure Virtual Machine Scale Set (VMSS). Only the fields a +// discoverer prices on are modeled: the SKU (VM size / tier / instance count) +// and the per-VM profile (Spot priority, hybrid-benefit license, OS type). +type ScaleSet struct { + Name string + ID string + Location string + SKUName string + SKUTier string + Capacity int + Priority string // Spot / Regular + LicenseType string + OSType string // Linux / Windows + Tags map[string]string +} + +// CreateScaleSet stores a VMSS, defaulting the fields real Azure fills in. +func (m *Mock) CreateScaleSet(_ context.Context, s ScaleSet) (*ScaleSet, error) { + if s.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "scale set name is required") + } + + if s.ID == "" { + s.ID = fmt.Sprintf( + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachineScaleSets/%s", s.Name) + } + + if s.Location == "" { + s.Location = m.opts.Region + } + + if s.SKUName == "" { + s.SKUName = "Standard_D2s_v3" + } + + if s.SKUTier == "" { + s.SKUTier = "Standard" + } + + if s.Capacity == 0 { + s.Capacity = 1 + } + + if s.Priority == "" { + s.Priority = "Regular" + } + + if s.OSType == "" { + s.OSType = "Linux" + } + + stored := s + + m.scaleSets.Set(s.Name, &stored) + + out := stored + + return &out, nil +} + +// ListScaleSets returns every stored VMSS. +func (m *Mock) ListScaleSets(_ context.Context) ([]ScaleSet, error) { + stored := m.scaleSets.SortedValues() + + out := make([]ScaleSet, 0, len(stored)) + for _, s := range stored { + out = append(out, *s) + } + + return out, nil +} diff --git a/providers/azure/virtualmachines/scaleset_test.go b/providers/azure/virtualmachines/scaleset_test.go new file mode 100644 index 00000000..87e91df2 --- /dev/null +++ b/providers/azure/virtualmachines/scaleset_test.go @@ -0,0 +1,116 @@ +package virtualmachines + +import ( + "context" + "testing" + + "github.com/stackshy/cloudemu/v2/services/compute/driver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateScaleSetDefaults(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + ss, err := m.CreateScaleSet(ctx, ScaleSet{Name: "vmss-defaults"}) + require.NoError(t, err) + + assert.Equal(t, "vmss-defaults", ss.Name) + assert.NotEmpty(t, ss.ID) + assert.NotEmpty(t, ss.Location) + assert.Equal(t, "Standard_D2s_v3", ss.SKUName) + assert.Equal(t, "Standard", ss.SKUTier) + assert.Equal(t, 1, ss.Capacity) + assert.Equal(t, "Regular", ss.Priority) + assert.Equal(t, "Linux", ss.OSType) +} + +func TestCreateScaleSetRequiresName(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + _, err := m.CreateScaleSet(ctx, ScaleSet{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "name is required") +} + +func TestCreateScaleSetExplicitRoundTrip(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + in := ScaleSet{ + Name: "vmss-spot", + Location: "westus2", + SKUName: "Standard_F4s_v2", + SKUTier: "Standard", + Capacity: 7, + Priority: "Spot", + LicenseType: "Windows_Server", + OSType: "Windows", + Tags: map[string]string{"env": "prod"}, + } + + created, err := m.CreateScaleSet(ctx, in) + require.NoError(t, err) + assert.Equal(t, in.Priority, created.Priority) + assert.Equal(t, in.LicenseType, created.LicenseType) + + list, err := m.ListScaleSets(ctx) + require.NoError(t, err) + require.Len(t, list, 1) + + got := list[0] + assert.Equal(t, "vmss-spot", got.Name) + assert.Equal(t, "westus2", got.Location) + assert.Equal(t, "Standard_F4s_v2", got.SKUName) + assert.Equal(t, "Standard", got.SKUTier) + assert.Equal(t, 7, got.Capacity) + assert.Equal(t, "Spot", got.Priority) + assert.Equal(t, "Windows_Server", got.LicenseType) + assert.Equal(t, "Windows", got.OSType) + assert.Equal(t, "prod", got.Tags["env"]) +} + +func TestRunInstancesCarriesCostFields(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + cfg := driver.InstanceConfig{ + ImageID: "img-1", + InstanceType: "Standard_B1s", + OSType: "Windows", + Priority: "Spot", + LicenseType: "Windows_Server", + Zones: []string{"1", "2"}, + } + + instances, err := m.RunInstances(ctx, cfg, 1) + require.NoError(t, err) + require.Len(t, instances, 1) + + inst := instances[0] + assert.Equal(t, "Windows", inst.OSType) + assert.Equal(t, "Spot", inst.Priority) + assert.Equal(t, "Windows_Server", inst.LicenseType) + assert.Equal(t, []string{"1", "2"}, inst.Zones) +} + +func TestCreateVolumeCarriesPerformanceFields(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + cfg := driver.VolumeConfig{ + Size: 256, + VolumeType: "Premium_LRS", + IOPS: 5000, + Throughput: 200, + Tier: "P15", + } + + vol, err := m.CreateVolume(ctx, cfg) + require.NoError(t, err) + assert.Equal(t, 5000, vol.IOPS) + assert.Equal(t, 200, vol.Throughput) + assert.Equal(t, "P15", vol.Tier) +} diff --git a/providers/azure/virtualmachines/vm.go b/providers/azure/virtualmachines/vm.go index a9b5778e..0696d22b 100644 --- a/providers/azure/virtualmachines/vm.go +++ b/providers/azure/virtualmachines/vm.go @@ -75,6 +75,10 @@ type instanceData struct { SecurityGroups []string Tags map[string]string LaunchTime string + OSType string + Priority string + LicenseType string + Zones []string } type asgData struct { @@ -92,6 +96,7 @@ type Mock struct { snapshots *memstore.Store[*driver.SnapshotInfo] images *memstore.Store[*driver.ImageInfo] keyPairs *memstore.Store[*driver.KeyPairInfo] + scaleSets *memstore.Store[*ScaleSet] sm *statemachine.Machine opts *config.Options ipCounter atomic.Int64 @@ -172,6 +177,7 @@ func New(opts *config.Options) *Mock { snapshots: memstore.New[*driver.SnapshotInfo](), images: memstore.New[*driver.ImageInfo](), keyPairs: memstore.New[*driver.KeyPairInfo](), + scaleSets: memstore.New[*ScaleSet](), sm: statemachine.New(compute.VMTransitions()), opts: opts, } @@ -196,6 +202,8 @@ func toInstance(d *instanceData) driver.Instance { ID: d.ID, ImageID: d.ImageID, InstanceType: d.InstanceType, State: d.State, PrivateIP: d.PrivateIP, PublicIP: d.PublicIP, SubnetID: d.SubnetID, VPCID: d.VPCID, SecurityGroups: sg, Tags: tags, LaunchTime: d.LaunchTime, + OSType: d.OSType, Priority: d.Priority, LicenseType: d.LicenseType, + Zones: append([]string(nil), d.Zones...), } } @@ -228,11 +236,17 @@ func (m *Mock) RunInstances(ctx context.Context, cfg driver.InstanceConfig, coun sg := make([]string, len(cfg.SecurityGroups)) copy(sg, cfg.SecurityGroups) + zones := append([]string(nil), cfg.Zones...) + inst := &instanceData{ ID: id, ImageID: cfg.ImageID, InstanceType: cfg.InstanceType, State: compute.StatePending, PrivateIP: m.nextIP(), SubnetID: cfg.SubnetID, SecurityGroups: sg, Tags: tags, - LaunchTime: m.opts.Clock.Now().UTC().Format("2006-01-02T15:04:05Z"), + LaunchTime: m.opts.Clock.Now().UTC().Format("2006-01-02T15:04:05Z"), + OSType: cfg.OSType, + Priority: cfg.Priority, + LicenseType: cfg.LicenseType, + Zones: zones, } m.instances.Set(id, inst) m.sm.SetState(id, compute.StatePending) @@ -412,6 +426,9 @@ func (m *Mock) CreateVolume(_ context.Context, cfg driver.VolumeConfig) (*driver AvailabilityZone: cfg.AvailabilityZone, CreatedAt: m.opts.Clock.Now().UTC().Format("2006-01-02T15:04:05Z"), Tags: copyTags(cfg.Tags), + IOPS: cfg.IOPS, + Throughput: cfg.Throughput, + Tier: cfg.Tier, } m.volumes.Set(id, vol) diff --git a/providers/azure/vnet/eip.go b/providers/azure/vnet/eip.go index ff454ced..a16f584f 100644 --- a/providers/azure/vnet/eip.go +++ b/providers/azure/vnet/eip.go @@ -9,11 +9,13 @@ import ( ) type eipData struct { - AllocationID string - PublicIP string - AssociationID string - InstanceID string - Tags map[string]string + AllocationID string + PublicIP string + AssociationID string + InstanceID string + Tags map[string]string + SKU string + AllocationMethod string } // AllocateAddress allocates a new public IP address. @@ -22,10 +24,24 @@ func (m *Mock) AllocateAddress( ) (*driver.ElasticIP, error) { allocID := idgen.GenerateID("ipalloc-") + // Real Azure defaults a public IP to the Standard SKU with Static allocation + // when the request omits them. + sku := cfg.SKU + if sku == "" { + sku = "Standard" + } + + allocMethod := cfg.AllocationMethod + if allocMethod == "" { + allocMethod = "Static" + } + eip := &eipData{ - AllocationID: allocID, - PublicIP: mockPublicIP(allocID), - Tags: copyTags(cfg.Tags), + AllocationID: allocID, + PublicIP: mockPublicIP(allocID), + Tags: copyTags(cfg.Tags), + SKU: sku, + AllocationMethod: allocMethod, } m.eips.Set(allocID, eip) @@ -113,10 +129,12 @@ func (m *Mock) DisassociateAddress( func toEIPInfo(eip *eipData) driver.ElasticIP { return driver.ElasticIP{ - AllocationID: eip.AllocationID, - PublicIP: eip.PublicIP, - AssociationID: eip.AssociationID, - InstanceID: eip.InstanceID, - Tags: copyTags(eip.Tags), + AllocationID: eip.AllocationID, + PublicIP: eip.PublicIP, + AssociationID: eip.AssociationID, + InstanceID: eip.InstanceID, + Tags: copyTags(eip.Tags), + SKU: eip.SKU, + AllocationMethod: eip.AllocationMethod, } } diff --git a/providers/azure/vnet/eip_cost_test.go b/providers/azure/vnet/eip_cost_test.go new file mode 100644 index 00000000..7d67421e --- /dev/null +++ b/providers/azure/vnet/eip_cost_test.go @@ -0,0 +1,41 @@ +package vnet + +import ( + "context" + "testing" + + "github.com/stackshy/cloudemu/v2/services/networking/driver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAllocateAddressDefaultsSKUAndAllocationMethod(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + eip, err := m.AllocateAddress(ctx, driver.ElasticIPConfig{}) + require.NoError(t, err) + assert.NotEmpty(t, eip.AllocationID) + assert.NotEmpty(t, eip.PublicIP) + assert.Equal(t, "Standard", eip.SKU) + assert.Equal(t, "Static", eip.AllocationMethod) +} + +func TestAllocateAddressExplicitSKUAndAllocationMethod(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + eip, err := m.AllocateAddress(ctx, driver.ElasticIPConfig{ + SKU: "Basic", + AllocationMethod: "Dynamic", + }) + require.NoError(t, err) + assert.Equal(t, "Basic", eip.SKU) + assert.Equal(t, "Dynamic", eip.AllocationMethod) + + got, err := m.DescribeAddresses(ctx, []string{eip.AllocationID}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "Basic", got[0].SKU) + assert.Equal(t, "Dynamic", got[0].AllocationMethod) +} diff --git a/providers/gcp/cloudfunctions/functions.go b/providers/gcp/cloudfunctions/functions.go index bac14a41..24759981 100644 --- a/providers/gcp/cloudfunctions/functions.go +++ b/providers/gcp/cloudfunctions/functions.go @@ -207,7 +207,20 @@ func (m *Mock) Invoke(ctx context.Context, input driver.InvokeInput) (*driver.In } if h == nil { - return &driver.InvokeOutput{StatusCode: 500, Error: "no handler registered"}, nil + // The emulator can't execute uploaded function code, so with no Go + // handler registered we return a successful stub echoing the request + // payload rather than a FunctionError — mirroring the AWS Lambda + // provider so identical cross-provider tests behave the same. + noHandlerDims := map[string]string{"function_name": input.FunctionName} + m.emitMetric(ctx, "function/execution_count", 1, noHandlerDims) + m.emitMetric(ctx, "function/execution_times", 1, noHandlerDims) + + payload := input.Payload + if len(payload) == 0 { + payload = []byte("{}") + } + + return &driver.InvokeOutput{StatusCode: 200, Payload: payload}, nil } dims := map[string]string{"function_name": input.FunctionName} diff --git a/providers/gcp/cloudfunctions/functions_test.go b/providers/gcp/cloudfunctions/functions_test.go index 7e642890..8680b0cf 100644 --- a/providers/gcp/cloudfunctions/functions_test.go +++ b/providers/gcp/cloudfunctions/functions_test.go @@ -160,7 +160,8 @@ func TestInvokeFunction(t *testing.T) { wantErr bool errSubstr string }{ - {name: "no handler", funcName: "echo", wantStatus: 500}, + // No Go handler → 200 stub echo (mirrors AWS Lambda, #319 review). + {name: "no handler", funcName: "echo", wantStatus: 200}, {name: "with handler", funcName: "echo", handler: func(_ context.Context, p []byte) ([]byte, error) { return append([]byte("echo:"), p...), nil }, payload: []byte("hi"), wantStatus: 200}, diff --git a/providers/gcp/gce/gce.go b/providers/gcp/gce/gce.go index 905b53c9..49630861 100644 --- a/providers/gcp/gce/gce.go +++ b/providers/gcp/gce/gce.go @@ -291,6 +291,20 @@ func (m *Mock) TerminateInstances(ctx context.Context, instanceIDs []string) err return m.transitionInstances(ctx, instanceIDs, terminateTransition) } +// RemoveInstance hard-deletes an instance, mirroring GCP's instances.delete +// (which removes the resource, unlike EC2 terminate which leaves a TERMINATED +// tombstone). GCP-specific; reached via a type assertion from the GCE handler. +func (m *Mock) RemoveInstance(_ context.Context, instanceID string) error { + if !m.instances.Has(instanceID) { + return cerrors.Newf(cerrors.NotFound, "instance %q not found", instanceID) + } + + m.instances.Delete(instanceID) + m.sm.Remove(instanceID) + + return nil +} + func (m *Mock) DescribeInstances( _ context.Context, instanceIDs []string, filters []driver.DescribeFilter, _ ...driver.DescribeInstancesOptions, ) ([]driver.Instance, error) { @@ -415,6 +429,9 @@ func (m *Mock) CreateVolume(_ context.Context, cfg driver.VolumeConfig) (*driver AvailabilityZone: cfg.AvailabilityZone, CreatedAt: m.opts.Clock.Now().UTC().Format("2006-01-02T15:04:05Z"), Tags: copyTags(cfg.Tags), + IOPS: cfg.IOPS, + Throughput: cfg.Throughput, + Tier: cfg.Tier, } m.volumes.Set(id, vol) @@ -514,8 +531,13 @@ func (m *Mock) DescribeSnapshots(_ context.Context, ids []string) ([]driver.Snap } func (m *Mock) CreateImage(_ context.Context, cfg driver.ImageConfig) (*driver.ImageInfo, error) { - if _, ok := m.instances.Get(cfg.InstanceID); !ok { - return nil, cerrors.Newf(cerrors.NotFound, "instance %q not found", cfg.InstanceID) + // GCP images are created from a disk, snapshot, or import — not from a + // source instance. An empty InstanceID is one of those source-based paths, + // so only validate when a specific instance was named (the EC2-style path). + if cfg.InstanceID != "" { + if _, ok := m.instances.Get(cfg.InstanceID); !ok { + return nil, cerrors.Newf(cerrors.NotFound, "instance %q not found", cfg.InstanceID) + } } id := fmt.Sprintf("projects/%s/global/images/img-%d", diff --git a/providers/gcp/gcp.go b/providers/gcp/gcp.go index 4e5f685f..e52ee669 100644 --- a/providers/gcp/gcp.go +++ b/providers/gcp/gcp.go @@ -48,7 +48,7 @@ func (a gkeDiscovery) DiscoverClusters(ctx context.Context) ([]resourcediscovery Name: c.Name, Region: c.Location, Tags: c.ResourceLabels, - NodeGroups: c.NodePoolNames, + NodeGroups: resourcediscovery.NodeGroupsFromNames(c.NodePoolNames), }) } diff --git a/providers/gcp/gke/gke.go b/providers/gcp/gke/gke.go index a65a2129..920e138a 100644 --- a/providers/gcp/gke/gke.go +++ b/providers/gcp/gke/gke.go @@ -60,6 +60,8 @@ type Cluster struct { IPRotationActive bool NodePoolNames []string Status string + MasterVersion string + NodeVersion string CreatedAt time.Time } @@ -391,6 +393,14 @@ func (m *Mock) UpdateCluster( if input.ResourceLabels != nil { c.ResourceLabels = copyLabels(input.ResourceLabels) } + + if input.MasterVersion != "" { + c.MasterVersion = input.MasterVersion + } + + if input.NodeVersion != "" { + c.NodeVersion = input.NodeVersion + } }) } diff --git a/providers/gcp/secretmanager/secretmanager.go b/providers/gcp/secretmanager/secretmanager.go index 995f37e6..ce317a9b 100644 --- a/providers/gcp/secretmanager/secretmanager.go +++ b/providers/gcp/secretmanager/secretmanager.go @@ -65,20 +65,22 @@ func (m *Mock) CreateSecret(_ context.Context, cfg driver.SecretConfig, value [] Tags: tags, } - data := make([]byte, len(value)) - copy(data, value) - - versionID := idgen.GenerateID("ver-") - version := driver.SecretVersion{ - VersionID: versionID, - Value: data, - CreatedAt: now, - Current: true, - } - - sd := &secretData{ - info: info, - versions: []driver.SecretVersion{version}, + sd := &secretData{info: info} + + // GCP's secrets.create makes an empty container — the first version is added + // separately via addVersion. Only seed a version when a value is actually + // supplied (the AWS-style create-with-value path); otherwise the secret has + // zero versions and access(latest) fails until one is added, matching GCP. + if len(value) > 0 { + data := make([]byte, len(value)) + copy(data, value) + + sd.versions = []driver.SecretVersion{{ + VersionID: idgen.GenerateID("ver-"), + Value: data, + CreatedAt: now, + Current: true, + }} } m.secrets.Set(cfg.Name, sd) diff --git a/server/aws/aws.go b/server/aws/aws.go index 33b50d23..3b0e6f24 100644 --- a/server/aws/aws.go +++ b/server/aws/aws.go @@ -27,6 +27,7 @@ import ( keyspacessrv "github.com/stackshy/cloudemu/v2/server/aws/keyspaces" "github.com/stackshy/cloudemu/v2/server/aws/lambda" memorydbsrv "github.com/stackshy/cloudemu/v2/server/aws/memorydb" + networkfirewallsrv "github.com/stackshy/cloudemu/v2/server/aws/networkfirewall" "github.com/stackshy/cloudemu/v2/server/aws/rds" "github.com/stackshy/cloudemu/v2/server/aws/redshift" "github.com/stackshy/cloudemu/v2/server/aws/resourceexplorer2" @@ -57,6 +58,7 @@ import ( mdbdriver "github.com/stackshy/cloudemu/v2/services/memorydb/driver" mqdriver "github.com/stackshy/cloudemu/v2/services/messagequeue/driver" mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" + nfdriver "github.com/stackshy/cloudemu/v2/services/networkfirewall/driver" netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" notifdriver "github.com/stackshy/cloudemu/v2/services/notification/driver" ssmdriver "github.com/stackshy/cloudemu/v2/services/parameterstore/driver" @@ -117,6 +119,9 @@ type Drivers struct { // MemoryDB serves the AWS MemoryDB JSON 1.1 protocol (Redis/Valkey cluster // control plane) against the memorydb driver. MemoryDB mdbdriver.MemoryDB + // NetworkFirewall serves the AWS Network Firewall JSON 1.0 protocol against + // the networkfirewall driver. + NetworkFirewall nfdriver.NetworkFirewall // SNS serves the SNS query protocol against the notification driver. SNS notifdriver.Notification // STS serves the AWS STS query protocol (GetCallerIdentity, AssumeRole, @@ -171,6 +176,7 @@ func DriversFrom(p *awsprovider.Provider) Drivers { ElastiCache: p.ElastiCache, Keyspaces: p.Keyspaces, MemoryDB: p.MemoryDB, + NetworkFirewall: p.NetworkFirewall, SNS: p.SNS, STS: true, K8sAPI: nil, // injected by the caller when a shared cluster is desired @@ -211,7 +217,12 @@ func New(d Drivers) *server.Server { srv := server.New() if d.CloudWatch != nil { - srv.Register(cloudwatch.New(d.CloudWatch)) + // The VPC driver optionally supplies derived AWS/IPAM metrics; surface + // them through CloudWatch when it implements the capability. + ipamMetrics, _ := d.VPC.(netdriver.IPAMMetrics) + cw := cloudwatch.New(d.CloudWatch) + cw.SetIPAMMetrics(ipamMetrics) + srv.Register(cw) } if d.DynamoDB != nil { @@ -272,7 +283,7 @@ func New(d Drivers) *server.Server { // EventBridge matches the X-Amz-Target prefix "AWSEvents." — disjoint from // DynamoDB, SQS, ECR, SageMaker, Secrets Manager, and the tagging API. if d.EventBridge != nil { - srv.Register(eventbridge.New(d.EventBridge)) + srv.Register(eventbridge.New(d.EventBridge, d.AccountID, d.Region)) } // CloudWatch Logs matches the X-Amz-Target prefix "Logs_20140328." — @@ -313,6 +324,13 @@ func New(d Drivers) *server.Server { srv.Register(memorydbsrv.New(d.MemoryDB)) } + // Network Firewall speaks AWS JSON 1.0 and matches on the + // "NetworkFirewall_20201112." target prefix, so its dispatch is disjoint + // from every other handler. + if d.NetworkFirewall != nil { + srv.Register(networkfirewallsrv.New(d.NetworkFirewall)) + } + // Keyspaces speaks AWS JSON 1.0 and matches on the "KeyspacesService." target // prefix, so its dispatch is disjoint from every other handler. if d.Keyspaces != nil { diff --git a/server/aws/bedrock/sdk_roundtrip_test.go b/server/aws/bedrock/sdk_roundtrip_test.go index 08f0a76f..9b39b2d2 100644 --- a/server/aws/bedrock/sdk_roundtrip_test.go +++ b/server/aws/bedrock/sdk_roundtrip_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "net/http" "net/http/httptest" "testing" @@ -212,6 +213,13 @@ func newRuntimeClient(t *testing.T) *awsruntime.Client { return awsruntime.NewFromConfig(cfg, func(o *awsruntime.Options) { o.BaseEndpoint = aws.String(newServer(t)) + // Disable HTTP keep-alives for the streaming (eventstream) client. + // Reusing a pooled connection races the httptest server's teardown: + // the eventstream reader can observe "use of closed network + // connection" instead of a clean EOF once the stream is fully + // consumed, flaking under CI load. A fresh, server-closed connection + // per request makes the stream end deterministically. + o.HTTPClient = &http.Client{Transport: &http.Transport{DisableKeepAlives: true}} }) } diff --git a/server/aws/bedrock/streaming.go b/server/aws/bedrock/streaming.go index 801f1180..c2ede60e 100644 --- a/server/aws/bedrock/streaming.go +++ b/server/aws/bedrock/streaming.go @@ -67,6 +67,14 @@ func (h *Handler) converseStream(w http.ResponseWriter, r *http.Request, modelID return } + // Drain any bytes the JSON decoder left unread (e.g. a trailing newline) + // before switching to a streamed chunked response. With the request body + // unread, net/http can't finish the connection gracefully and tears it down + // when the handler returns, which under load races the client's in-flight + // read of the event stream and surfaces as "use of closed network + // connection". invokeModelStream already reads the whole body via io.ReadAll. + _, _ = io.Copy(io.Discard, r.Body) + out, err := h.bedrock.Converse(r.Context(), toConverseInput(modelID, &in)) if err != nil { writeErr(w, err) diff --git a/server/aws/cloudwatch/handler.go b/server/aws/cloudwatch/handler.go index 5943fcff..84e707da 100644 --- a/server/aws/cloudwatch/handler.go +++ b/server/aws/cloudwatch/handler.go @@ -21,6 +21,7 @@ import ( cerrors "github.com/stackshy/cloudemu/v2/errors" mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" ) const ( @@ -33,26 +34,46 @@ const ( ) // Handler serves CloudWatch rpc-v2-cbor requests against a monitoring driver. +// An optional IPAM metrics source lets the handler surface derived AWS/IPAM +// metrics that the monitoring store itself doesn't hold. type Handler struct { monitoring mondriver.Monitoring + ipam netdriver.IPAMMetrics } -// New returns a CloudWatch handler backed by m. +// New returns a CloudWatch handler backed by m. Use SetIPAMMetrics to attach +// the optional derived AWS/IPAM metrics source. Kept single-argument so callers +// that don't wire IPAM (e.g. the base query-protocol tests) construct it +// unchanged. func New(m mondriver.Monitoring) *Handler { return &Handler{monitoring: m} } -// Matches returns true for Smithy rpc-v2-cbor requests. +// SetIPAMMetrics attaches an optional IPAMMetrics source (nil-safe) supplying +// the derived AWS/IPAM namespace metrics, following the same setter-injection +// pattern as the other CloudEmu handlers. +func (h *Handler) SetIPAMMetrics(ipam netdriver.IPAMMetrics) { + h.ipam = ipam +} + +// Matches returns true for Smithy rpc-v2-cbor requests, and for classic +// query-protocol CloudWatch requests (used by the AWS CLI and older SDKs), +// disambiguated from EC2 by the SigV4 "monitoring" credential scope. func (*Handler) Matches(r *http.Request) bool { - if r.Header.Get(protocolHeader) != protocolValue { - return false + if r.Header.Get(protocolHeader) == protocolValue && strings.HasPrefix(r.URL.Path, pathPrefix) { + return true } - return strings.HasPrefix(r.URL.Path, pathPrefix) + return isQueryRequest(r) } // ServeHTTP parses the URL path for the operation name and dispatches. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if isQueryRequest(r) { + h.serveQuery(w, r) + return + } + op := extractOperation(r.URL.Path) if op == "" { writeCBORError(w, http.StatusBadRequest, "InvalidRequest", "missing operation in path") diff --git a/server/aws/cloudwatch/ops.go b/server/aws/cloudwatch/ops.go index 8a90eb81..2f441753 100644 --- a/server/aws/cloudwatch/ops.go +++ b/server/aws/cloudwatch/ops.go @@ -1,12 +1,21 @@ package cloudwatch import ( + "context" "net/http" "time" "github.com/fxamacker/cbor/v2" mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +const ( + statSum = "Sum" + statMinimum = "Minimum" + statMaximum = "Maximum" + statSampleCount = "SampleCount" ) // putMetricDataInput mirrors the AWS wire shape for the operation. Field @@ -111,6 +120,11 @@ func (h *Handler) getMetricStatistics(w http.ResponseWriter, r *http.Request, bo end = *in.EndTime } + if h.ipam != nil && in.Namespace == netdriver.IpamMetricNamespace { + h.getIpamMetricStatistics(w, r, in.MetricName, toDimensionMap(in.Dimensions), stat) + return + } + input := mondriver.GetMetricInput{ Namespace: in.Namespace, MetricName: in.MetricName, @@ -133,6 +147,51 @@ func (h *Handler) getMetricStatistics(w http.ResponseWriter, r *http.Request, bo }) } +// getIpamMetricStatistics returns a single datapoint for a derived AWS/IPAM +// metric, matched by name and (if supplied) dimensions. +func (h *Handler) getIpamMetricStatistics(w http.ResponseWriter, r *http.Request, name string, dims map[string]string, stat string) { + for _, mtr := range h.ipam.IpamMetrics(r.Context()) { + if mtr.MetricName != name || !dimensionsMatch(mtr.Dimensions, dims) { + continue + } + + dp := datapointCBR{Timestamp: time.Unix(0, 0).UTC(), Unit: mtr.Unit} + setDatapointStat(&dp, stat, mtr.Value) + + writeCBORResponse(w, getMetricStatisticsOutput{Label: name, Datapoints: []datapointCBR{dp}}) + + return + } + + writeCBORResponse(w, getMetricStatisticsOutput{Label: name, Datapoints: nil}) +} + +// dimensionsMatch reports whether every requested dimension is present in have. +func dimensionsMatch(have, want map[string]string) bool { + for k, v := range want { + if have[k] != v { + return false + } + } + + return true +} + +func setDatapointStat(dp *datapointCBR, stat string, value float64) { + switch stat { + case statSum: + dp.Sum = value + case statMinimum: + dp.Minimum = value + case statMaximum: + dp.Maximum = value + case statSampleCount: + dp.SampleCount = value + default: + dp.Average = value + } +} + type listMetricsInput struct { Namespace string `cbor:"Namespace,omitempty"` } @@ -154,6 +213,31 @@ func (h *Handler) listMetrics(w http.ResponseWriter, r *http.Request, body []byt return } + // An exact AWS/IPAM request returns only the synthetic IPAM metrics. + if h.ipam != nil && in.Namespace == netdriver.IpamMetricNamespace { + writeCBORResponse(w, listMetricsOutput{Metrics: h.ipamMetricRows(r)}) + return + } + + // A namespace-less "list all" request must return every real metric (with + // its true namespace) and, when IPAM is wired, the IPAM metrics merged in — + // never one set replacing the other. + if in.Namespace == "" { + out, err := h.allMetricRows(r) + if err != nil { + writeDriverErr(w, err) + return + } + + if h.ipam != nil { + out = append(out, h.ipamMetricRows(r)...) + } + + writeCBORResponse(w, listMetricsOutput{Metrics: out}) + + return + } + names, err := h.monitoring.ListMetrics(r.Context(), in.Namespace) if err != nil { writeDriverErr(w, err) @@ -168,6 +252,61 @@ func (h *Handler) listMetrics(w http.ResponseWriter, r *http.Request, body []byt writeCBORResponse(w, listMetricsOutput{Metrics: out}) } +// detailedMetricLister is the AWS-local capability that enumerates every metric +// with its namespace, backing a namespace-less ListMetrics. The shared +// Monitoring interface only lists names within a single namespace. +type detailedMetricLister interface { + ListMetricsDetailed(ctx context.Context) ([]mondriver.MetricIdentifier, error) +} + +// allMetricRows lists every real metric tagged with its true namespace, using +// the detailed lister when available and otherwise degrading to the +// empty-namespace name list. +func (h *Handler) allMetricRows(r *http.Request) ([]metricCBR, error) { + if dl, ok := h.monitoring.(detailedMetricLister); ok { + ids, err := dl.ListMetricsDetailed(r.Context()) + if err != nil { + return nil, err + } + + out := make([]metricCBR, 0, len(ids)) + for _, id := range ids { + out = append(out, metricCBR{Namespace: id.Namespace, MetricName: id.MetricName}) + } + + return out, nil + } + + names, err := h.monitoring.ListMetrics(r.Context(), "") + if err != nil { + return nil, err + } + + out := make([]metricCBR, 0, len(names)) + for _, name := range names { + out = append(out, metricCBR{MetricName: name}) + } + + return out, nil +} + +// ipamMetricRows returns the derived AWS/IPAM metrics with their dimensions. +func (h *Handler) ipamMetricRows(r *http.Request) []metricCBR { + metrics := h.ipam.IpamMetrics(r.Context()) + out := make([]metricCBR, 0, len(metrics)) + + for _, mtr := range metrics { + dims := make([]dimensionCBR, 0, len(mtr.Dimensions)) + for k, v := range mtr.Dimensions { + dims = append(dims, dimensionCBR{Name: k, Value: v}) + } + + out = append(out, metricCBR{Namespace: netdriver.IpamMetricNamespace, MetricName: mtr.MetricName, Dimensions: dims}) + } + + return out +} + type putMetricAlarmInput struct { AlarmName string `cbor:"AlarmName"` Namespace string `cbor:"Namespace"` @@ -309,13 +448,13 @@ func toDatapointsCBR(res *mondriver.MetricDataResult, stat string) []datapointCB v := res.Values[i] switch stat { - case "Sum": + case statSum: dp.Sum = v - case "Minimum": + case statMinimum: dp.Minimum = v - case "Maximum": + case statMaximum: dp.Maximum = v - case "SampleCount": + case statSampleCount: dp.SampleCount = v default: dp.Average = v diff --git a/server/aws/cloudwatch/query.go b/server/aws/cloudwatch/query.go new file mode 100644 index 00000000..a8642371 --- /dev/null +++ b/server/aws/cloudwatch/query.go @@ -0,0 +1,387 @@ +package cloudwatch + +// CloudWatch's AWS CLI (and older SDKs) use the classic AWS **query protocol** +// (form-encoded POST, `Action=...`, XML responses) rather than rpc-v2-cbor. +// This file adds that path so `aws cloudwatch ...` works against the emulator. +// Query requests are disambiguated from EC2 (which also claims form POSTs) by +// the SigV4 credential scope service, which is "monitoring" for CloudWatch. + +import ( + "encoding/xml" + "net/http" + "strconv" + "strings" + "time" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" +) + +const ( + queryNamespace = "http://monitoring.amazonaws.com/doc/2010-08-01/" + queryRequestID = "00000000-0000-0000-0000-000000000000" + sigV4Service = "monitoring" +) + +// isQueryRequest reports whether r is a CloudWatch query-protocol request: +// a form-encoded POST (or GET with Action) whose SigV4 credential scope names +// the "monitoring" service. +func isQueryRequest(r *http.Request) bool { + if r.Header.Get(protocolHeader) == protocolValue { + return false // rpc-v2-cbor, handled elsewhere + } + + if r.URL.Query().Get("Action") == "" && + !(r.Method == http.MethodPost && strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded")) { + return false + } + + return sigV4ScopeService(r.Header.Get("Authorization")) == sigV4Service +} + +// sigV4ScopeService extracts the service from a SigV4 Authorization header's +// credential scope: "Credential=AKID/20260101/us-east-1//aws4_request". +func sigV4ScopeService(auth string) string { + i := strings.Index(auth, "Credential=") + if i < 0 { + return "" + } + + scope := auth[i+len("Credential="):] + if j := strings.IndexByte(scope, ','); j >= 0 { + scope = scope[:j] + } + + parts := strings.Split(scope, "/") + if len(parts) < 5 { + return "" + } + + return parts[3] +} + +// serveQuery handles a CloudWatch query-protocol request. +func (h *Handler) serveQuery(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + writeQueryError(w, http.StatusBadRequest, "MalformedQueryString", err.Error()) + return + } + + switch r.Form.Get("Action") { + case "PutMetricData": + h.queryPutMetricData(w, r) + case "ListMetrics": + h.queryListMetrics(w, r) + case "GetMetricStatistics": + h.queryGetMetricStatistics(w, r) + case "PutMetricAlarm": + h.queryPutMetricAlarm(w, r) + case "DescribeAlarms": + h.queryDescribeAlarms(w, r) + case "DeleteAlarms": + h.queryDeleteAlarms(w, r) + case "SetAlarmState": + h.querySetAlarmState(w, r) + default: + writeQueryError(w, http.StatusBadRequest, "InvalidAction", "unsupported CloudWatch action: "+r.Form.Get("Action")) + } +} + +func (h *Handler) queryPutMetricData(w http.ResponseWriter, r *http.Request) { + ns := r.Form.Get("Namespace") + + var data []mondriver.MetricDatum + + for i := 1; ; i++ { + p := "MetricData.member." + strconv.Itoa(i) + "." + name := r.Form.Get(p + "MetricName") + if name == "" { + break + } + + val, _ := strconv.ParseFloat(r.Form.Get(p+"Value"), 64) + + ts := time.Now().UTC() + if raw := r.Form.Get(p + "Timestamp"); raw != "" { + if parsed, err := time.Parse(time.RFC3339, raw); err == nil { + ts = parsed + } + } + + data = append(data, mondriver.MetricDatum{ + Namespace: ns, MetricName: name, Value: val, Unit: r.Form.Get(p + "Unit"), + Dimensions: queryDimensions(r, p+"Dimensions.member."), Timestamp: ts, + }) + } + + if err := h.monitoring.PutMetricData(r.Context(), data); err != nil { + writeQueryDriverErr(w, err) + return + } + + writeQueryResponse(w, "PutMetricDataResponse", nil) +} + +func (h *Handler) queryListMetrics(w http.ResponseWriter, r *http.Request) { + names, err := h.monitoring.ListMetrics(r.Context(), r.Form.Get("Namespace")) + if err != nil { + writeQueryDriverErr(w, err) + return + } + + ns := r.Form.Get("Namespace") + members := make([]metricMemberXML, 0, len(names)) + + for _, n := range names { + members = append(members, metricMemberXML{Namespace: ns, MetricName: n}) + } + + writeQueryResponse(w, "ListMetricsResponse", listMetricsResultXML{Metrics: members}) +} + +func (h *Handler) queryGetMetricStatistics(w http.ResponseWriter, r *http.Request) { + stat := r.Form.Get("Statistics.member.1") + if stat == "" { + stat = "Average" + } + + start, _ := time.Parse(time.RFC3339, r.Form.Get("StartTime")) + end, _ := time.Parse(time.RFC3339, r.Form.Get("EndTime")) + period, _ := strconv.Atoi(r.Form.Get("Period")) + + res, err := h.monitoring.GetMetricData(r.Context(), mondriver.GetMetricInput{ + Namespace: r.Form.Get("Namespace"), MetricName: r.Form.Get("MetricName"), + Dimensions: queryDimensions(r, "Dimensions.member."), StartTime: start, EndTime: end, + Period: period, Stat: stat, + }) + if err != nil { + writeQueryDriverErr(w, err) + return + } + + var dps []datapointXML + + if res != nil { + for i := range res.Timestamps { + dp := datapointXML{Timestamp: res.Timestamps[i].UTC().Format(time.RFC3339), Unit: "Count"} + setQueryStat(&dp, stat, res.Values[i]) + dps = append(dps, dp) + } + } + + writeQueryResponse(w, "GetMetricStatisticsResponse", getStatsResultXML{Label: r.Form.Get("MetricName"), Datapoints: dps}) +} + +func (h *Handler) queryPutMetricAlarm(w http.ResponseWriter, r *http.Request) { + threshold, _ := strconv.ParseFloat(r.Form.Get("Threshold"), 64) + period, _ := strconv.Atoi(r.Form.Get("Period")) + evalPeriods, _ := strconv.Atoi(r.Form.Get("EvaluationPeriods")) + + err := h.monitoring.CreateAlarm(r.Context(), mondriver.AlarmConfig{ + Name: r.Form.Get("AlarmName"), Namespace: r.Form.Get("Namespace"), MetricName: r.Form.Get("MetricName"), + Dimensions: queryDimensions(r, "Dimensions.member."), ComparisonOperator: r.Form.Get("ComparisonOperator"), + Threshold: threshold, Period: period, EvaluationPeriods: evalPeriods, Stat: r.Form.Get("Statistic"), + AlarmActions: queryStringList(r, "AlarmActions.member."), OKActions: queryStringList(r, "OKActions.member."), + }) + if err != nil { + writeQueryDriverErr(w, err) + return + } + + writeQueryResponse(w, "PutMetricAlarmResponse", nil) +} + +func (h *Handler) queryDescribeAlarms(w http.ResponseWriter, r *http.Request) { + alarms, err := h.monitoring.DescribeAlarms(r.Context(), queryStringList(r, "AlarmNames.member.")) + if err != nil { + writeQueryDriverErr(w, err) + return + } + + members := make([]alarmMemberXML, 0, len(alarms)) + for i := range alarms { + members = append(members, alarmMemberXML{ + AlarmName: alarms[i].Name, Namespace: alarms[i].Namespace, MetricName: alarms[i].MetricName, + StateValue: alarms[i].State, ComparisonOperator: alarms[i].ComparisonOperator, Threshold: alarms[i].Threshold, + }) + } + + writeQueryResponse(w, "DescribeAlarmsResponse", describeAlarmsResultXML{MetricAlarms: members}) +} + +func (h *Handler) queryDeleteAlarms(w http.ResponseWriter, r *http.Request) { + for _, name := range queryStringList(r, "AlarmNames.member.") { + if err := h.monitoring.DeleteAlarm(r.Context(), name); err != nil { + writeQueryDriverErr(w, err) + return + } + } + + writeQueryResponse(w, "DeleteAlarmsResponse", nil) +} + +func (h *Handler) querySetAlarmState(w http.ResponseWriter, r *http.Request) { + err := h.monitoring.SetAlarmState(r.Context(), r.Form.Get("AlarmName"), r.Form.Get("StateValue"), r.Form.Get("StateReason")) + if err != nil { + writeQueryDriverErr(w, err) + return + } + + writeQueryResponse(w, "SetAlarmStateResponse", nil) +} + +// ---- form list helpers ---- + +func queryDimensions(r *http.Request, prefix string) map[string]string { + var out map[string]string + + for i := 1; ; i++ { + name := r.Form.Get(prefix + strconv.Itoa(i) + ".Name") + if name == "" { + break + } + + if out == nil { + out = map[string]string{} + } + + out[name] = r.Form.Get(prefix + strconv.Itoa(i) + ".Value") + } + + return out +} + +func queryStringList(r *http.Request, prefix string) []string { + var out []string + + for i := 1; ; i++ { + v := r.Form.Get(prefix + strconv.Itoa(i)) + if v == "" { + break + } + + out = append(out, v) + } + + return out +} + +func setQueryStat(dp *datapointXML, stat string, v float64) { + switch stat { + case "Sum": + dp.Sum = v + case "Minimum": + dp.Minimum = v + case "Maximum": + dp.Maximum = v + case "SampleCount": + dp.SampleCount = v + default: + dp.Average = v + } +} + +// ---- XML response shapes (query protocol, 2010-08-01) ---- + +type metricMemberXML struct { + Namespace string `xml:"Namespace"` + MetricName string `xml:"MetricName"` +} + +type listMetricsResultXML struct { + XMLName xml.Name `xml:"ListMetricsResult"` + Metrics []metricMemberXML `xml:"Metrics>member"` +} + +type datapointXML struct { + Timestamp string `xml:"Timestamp"` + SampleCount float64 `xml:"SampleCount,omitempty"` + Average float64 `xml:"Average,omitempty"` + Sum float64 `xml:"Sum,omitempty"` + Minimum float64 `xml:"Minimum,omitempty"` + Maximum float64 `xml:"Maximum,omitempty"` + Unit string `xml:"Unit,omitempty"` +} + +type getStatsResultXML struct { + XMLName xml.Name `xml:"GetMetricStatisticsResult"` + Label string `xml:"Label"` + Datapoints []datapointXML `xml:"Datapoints>member"` +} + +type alarmMemberXML struct { + AlarmName string `xml:"AlarmName"` + Namespace string `xml:"Namespace"` + MetricName string `xml:"MetricName"` + StateValue string `xml:"StateValue"` + ComparisonOperator string `xml:"ComparisonOperator"` + Threshold float64 `xml:"Threshold"` +} + +type describeAlarmsResultXML struct { + XMLName xml.Name `xml:"DescribeAlarmsResult"` + MetricAlarms []alarmMemberXML `xml:"MetricAlarms>member"` +} + +// writeQueryResponse writes an AWS query-protocol XML envelope. result may be +// nil for actions that return only ResponseMetadata. +func writeQueryResponse(w http.ResponseWriter, root string, result any) { + type meta struct { + RequestID string `xml:"RequestId"` + } + + var buf strings.Builder + + buf.WriteString(``) + buf.WriteString(`<` + root + ` xmlns="` + queryNamespace + `">`) + + if result != nil { + // result structs carry their own XMLName. + inner, err := xml.Marshal(result) + if err != nil { + writeQueryError(w, http.StatusInternalServerError, "InternalFailure", err.Error()) + return + } + + buf.Write(inner) + } + + m, _ := xml.Marshal(struct { + meta `xml:"ResponseMetadata"` + }{meta{RequestID: queryRequestID}}) + buf.Write(m) + buf.WriteString(``) + + w.Header().Set("Content-Type", "text/xml") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(buf.String())) +} + +func writeQueryError(w http.ResponseWriter, status int, code, msg string) { + w.Header().Set("Content-Type", "text/xml") + w.WriteHeader(status) + _, _ = w.Write([]byte(`Sender` + code + `` + xmlEscape(msg) + + `` + queryRequestID + ``)) +} + +func writeQueryDriverErr(w http.ResponseWriter, err error) { + code, status := "InternalFailure", http.StatusInternalServerError + + switch { + case cerrors.IsNotFound(err): + code, status = "ResourceNotFound", http.StatusNotFound + case cerrors.IsInvalidArgument(err): + code, status = "InvalidParameterValue", http.StatusBadRequest + } + + writeQueryError(w, status, code, err.Error()) +} + +func xmlEscape(s string) string { + var b strings.Builder + + _ = xml.EscapeText(&b, []byte(s)) + + return b.String() +} diff --git a/server/aws/cloudwatch/query_test.go b/server/aws/cloudwatch/query_test.go new file mode 100644 index 00000000..d76f736e --- /dev/null +++ b/server/aws/cloudwatch/query_test.go @@ -0,0 +1,100 @@ +package cloudwatch_test + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + cwprovider "github.com/stackshy/cloudemu/v2/providers/aws/cloudwatch" + cwserver "github.com/stackshy/cloudemu/v2/server/aws/cloudwatch" +) + +// monitoringAuth is a SigV4 Authorization header whose credential scope names +// the "monitoring" service — exactly what the AWS CLI sends for CloudWatch. +const monitoringAuth = "AWS4-HMAC-SHA256 Credential=test/20260804/us-east-1/monitoring/aws4_request, SignedHeaders=host, Signature=x" + +// TestQueryProtocol verifies the CloudWatch handler serves the classic query +// protocol (form-encoded POST + XML) that the AWS CLI uses — regression guard +// for issue #319 (CloudWatch was previously stolen by the EC2 handler). +func TestQueryProtocol(t *testing.T) { + h := cwserver.New(cwprovider.New(config.NewOptions())) + ts := httptest.NewServer(h) + + t.Cleanup(ts.Close) + + post := func(form url.Values) (int, string) { + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", monitoringAuth) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("post: %v", err) + } + defer resp.Body.Close() + + b, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, string(b) + } + + // The handler must claim monitoring-scoped form POSTs. + if !h.Matches(mustReq(monitoringAuth)) { + t.Fatal("Matches should be true for a monitoring-scoped form POST") + } + // ...and must NOT claim ec2-scoped ones (those belong to the EC2 handler). + if h.Matches(mustReq(strings.Replace(monitoringAuth, "monitoring", "ec2", 1))) { + t.Fatal("Matches must be false for an ec2-scoped request") + } + + // PutMetricData → 200 + PutMetricDataResponse. + if code, body := post(url.Values{ + "Action": {"PutMetricData"}, "Namespace": {"MyApp"}, + "MetricData.member.1.MetricName": {"Requests"}, "MetricData.member.1.Value": {"42"}, + }); code != 200 || !strings.Contains(body, "PutMetricDataResponse") { + t.Fatalf("PutMetricData: code=%d body=%s", code, body) + } + + // ListMetrics → the metric we just put is present. + code, body := post(url.Values{"Action": {"ListMetrics"}, "Namespace": {"MyApp"}}) + if code != 200 || !strings.Contains(body, "Requests") { + t.Fatalf("ListMetrics: code=%d body=%s", code, body) + } + if !strings.Contains(body, "ListMetricsResult") { + t.Fatalf("ListMetrics missing result wrapper: %s", body) + } + + // PutMetricAlarm + DescribeAlarms round-trip. + if code, body := post(url.Values{ + "Action": {"PutMetricAlarm"}, "AlarmName": {"a1"}, "Namespace": {"MyApp"}, "MetricName": {"Requests"}, + "ComparisonOperator": {"GreaterThanThreshold"}, "EvaluationPeriods": {"1"}, "Period": {"60"}, + "Threshold": {"10"}, "Statistic": {"Average"}, + }); code != 200 { + t.Fatalf("PutMetricAlarm: code=%d body=%s", code, body) + } + + if code, body := post(url.Values{"Action": {"DescribeAlarms"}}); code != 200 || !strings.Contains(body, "a1") { + t.Fatalf("DescribeAlarms: code=%d body=%s", code, body) + } + + // SetAlarmState + DeleteAlarms. + if code, _ := post(url.Values{"Action": {"SetAlarmState"}, "AlarmName": {"a1"}, "StateValue": {"ALARM"}, "StateReason": {"t"}}); code != 200 { + t.Fatalf("SetAlarmState: code=%d", code) + } + + if code, _ := post(url.Values{"Action": {"DeleteAlarms"}, "AlarmNames.member.1": {"a1"}}); code != 200 { + t.Fatalf("DeleteAlarms: code=%d", code) + } +} + +func mustReq(auth string) *http.Request { + r, _ := http.NewRequest(http.MethodPost, "http://x/", strings.NewReader("Action=ListMetrics")) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.Header.Set("Authorization", auth) + + return r +} diff --git a/server/aws/cloudwatchlogs/handler.go b/server/aws/cloudwatchlogs/handler.go index f336a4ed..d5022fe4 100644 --- a/server/aws/cloudwatchlogs/handler.go +++ b/server/aws/cloudwatchlogs/handler.go @@ -70,12 +70,42 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.getLogEvents(w, r) case "FilterLogEvents": h.filterLogEvents(w, r) + case "PutRetentionPolicy": + h.putRetentionPolicy(w, r) + case "TagResource", "TagLogGroup": + h.tagLogGroup(w, r) + case "UntagResource", "UntagLogGroup": + h.untagLogGroup(w, r) + case "ListTagsForResource", "ListTagsLogGroup": + h.listTagsForResource(w, r) default: wire.WriteJSONError(w, http.StatusBadRequest, "UnknownOperationException", "unknown CloudWatch Logs operation: "+op) } } +// putRetentionPolicy sets a log group's retention (SSM PutRetentionPolicy), +// backed by the driver's UpdateLogGroup. +func (h *Handler) putRetentionPolicy(w http.ResponseWriter, r *http.Request) { + var req struct { + LogGroupName string `json:"logGroupName"` + RetentionInDays int `json:"retentionInDays"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if _, err := h.logs.UpdateLogGroup(r.Context(), logdriver.LogGroupConfig{ + Name: req.LogGroupName, RetentionDays: req.RetentionInDays, + }); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + // writeErr maps canonical cloudemu errors to CloudWatch Logs JSON error // responses. Like the other AWS JSON 1.1 services, errors are HTTP 400 with a // "__type" body the SDK maps to a typed exception. diff --git a/server/aws/cloudwatchlogs/sdk_roundtrip_test.go b/server/aws/cloudwatchlogs/sdk_roundtrip_test.go index 0db41438..6001becc 100644 --- a/server/aws/cloudwatchlogs/sdk_roundtrip_test.go +++ b/server/aws/cloudwatchlogs/sdk_roundtrip_test.go @@ -81,6 +81,36 @@ func TestSDKLogGroupLifecycle(t *testing.T) { } } +// TestSDKPutRetentionPolicy is a regression guard for issue #319: +// PutRetentionPolicy was unimplemented (UnknownOperationException). +func TestSDKPutRetentionPolicy(t *testing.T) { + client := newLogsClient(t) + ctx := context.Background() + + if _, err := client.CreateLogGroup(ctx, &cwl.CreateLogGroupInput{ + LogGroupName: aws.String("/app/ret"), + }); err != nil { + t.Fatalf("CreateLogGroup: %v", err) + } + + if _, err := client.PutRetentionPolicy(ctx, &cwl.PutRetentionPolicyInput{ + LogGroupName: aws.String("/app/ret"), RetentionInDays: aws.Int32(14), + }); err != nil { + t.Fatalf("PutRetentionPolicy: %v", err) + } + + desc, err := client.DescribeLogGroups(ctx, &cwl.DescribeLogGroupsInput{ + LogGroupNamePrefix: aws.String("/app/ret"), + }) + if err != nil { + t.Fatalf("DescribeLogGroups: %v", err) + } + + if len(desc.LogGroups) != 1 || aws.ToInt32(desc.LogGroups[0].RetentionInDays) != 14 { + t.Fatalf("retention not applied: %+v", desc.LogGroups) + } +} + func TestSDKPutAndGetLogEvents(t *testing.T) { client := newLogsClient(t) ctx := context.Background() diff --git a/server/aws/cloudwatchlogs/tags.go b/server/aws/cloudwatchlogs/tags.go new file mode 100644 index 00000000..72f3a363 --- /dev/null +++ b/server/aws/cloudwatchlogs/tags.go @@ -0,0 +1,127 @@ +package cloudwatchlogs + +import ( + "context" + "net/http" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// logGroupTagger is the AWS-specific log-group tagging surface, asserted +// against the provider (not part of the portable Logging driver). +type logGroupTagger interface { + TagLogGroup(ctx context.Context, name string, tags map[string]string) error + UntagLogGroup(ctx context.Context, name string, keys []string) error + ListLogGroupTags(ctx context.Context, name string) (map[string]string, error) +} + +// logGroupName resolves either a log-group ARN (modern TagResource) or a bare +// name (legacy TagLogGroup) to the name the driver keys on. +func logGroupName(resourceArn, name string) string { + if name != "" { + return name + } + + const marker = ":log-group:" + + if i := strings.LastIndex(resourceArn, marker); i >= 0 { + return strings.TrimSuffix(resourceArn[i+len(marker):], ":*") + } + + return resourceArn +} + +func (h *Handler) logGroupTags() (logGroupTagger, bool) { + t, ok := h.logs.(logGroupTagger) + + return t, ok +} + +func (h *Handler) tagLogGroup(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.logGroupTags() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceArn string `json:"resourceArn"` + LogGroupName string `json:"logGroupName"` + Tags map[string]string `json:"tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + name := logGroupName(req.ResourceArn, req.LogGroupName) + + if err := tagger.TagLogGroup(r.Context(), name, req.Tags); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) untagLogGroup(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.logGroupTags() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceArn string `json:"resourceArn"` + LogGroupName string `json:"logGroupName"` + TagKeys []string `json:"tagKeys"` + Tags []string `json:"tags"` // legacy UntagLogGroup uses "tags" + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + name := logGroupName(req.ResourceArn, req.LogGroupName) + + keys := req.TagKeys + if len(keys) == 0 { + keys = req.Tags + } + + if err := tagger.UntagLogGroup(r.Context(), name, keys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) listTagsForResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.logGroupTags() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceArn string `json:"resourceArn"` + LogGroupName string `json:"logGroupName"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + name := logGroupName(req.ResourceArn, req.LogGroupName) + + tags, err := tagger.ListLogGroupTags(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{"tags": tags}) +} diff --git a/server/aws/dynamodb/dynamodb_lifecycle_test.go b/server/aws/dynamodb/dynamodb_lifecycle_test.go index f20cbffc..59e17b13 100644 --- a/server/aws/dynamodb/dynamodb_lifecycle_test.go +++ b/server/aws/dynamodb/dynamodb_lifecycle_test.go @@ -199,6 +199,42 @@ func TestDDBTableLifecycle(t *testing.T) { require.ErrorAs(t, err, &rnf, "DescribeTable on a deleted table should be ResourceNotFoundException") } +// TestDDBTagging is a regression guard for issue #319: TagResource / +// UntagResource / ListTagsOfResource returned UnknownOperationException. +func TestDDBTagging(t *testing.T) { + client, _ := newSuiteDDBEnv(t) + ctx := context.Background() + + suiteDDBCreateTable(t, client, "tagged", "pk", "sk") + + arn := "arn:aws:dynamodb:us-east-1:000000000000:table/tagged" + + if _, err := client.TagResource(ctx, &dynamodb.TagResourceInput{ + ResourceArn: aws.String(arn), + Tags: []ddbtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("prod")}, + {Key: aws.String("team"), Value: aws.String("data")}, + }, + }); err != nil { + t.Fatalf("TagResource: %v", err) + } + + list, err := client.ListTagsOfResource(ctx, &dynamodb.ListTagsOfResourceInput{ResourceArn: aws.String(arn)}) + require.NoError(t, err) + require.Len(t, list.Tags, 2) + + if _, err := client.UntagResource(ctx, &dynamodb.UntagResourceInput{ + ResourceArn: aws.String(arn), TagKeys: []string{"env"}, + }); err != nil { + t.Fatalf("UntagResource: %v", err) + } + + list, err = client.ListTagsOfResource(ctx, &dynamodb.ListTagsOfResourceInput{ResourceArn: aws.String(arn)}) + require.NoError(t, err) + require.Len(t, list.Tags, 1) + assert.Equal(t, "team", aws.ToString(list.Tags[0].Key)) +} + // TestDDBItemJourney: put an item with varied attribute types // (S, N incl. negative decimal, BOOL, NULL, L, M, empty string, ~100KB blob), // read it back through the SDK, update with SET+REMOVE (ReturnValues ALL_NEW), @@ -365,6 +401,72 @@ func TestDDBQueryPartitionAndSort(t *testing.T) { assert.Equal(t, "99", attrN(t, out.Items[0], "total")) } +// TestDDBTimeToLive is a regression guard for issue #319: +// DescribeTimeToLive / UpdateTimeToLive returned UnknownOperationException. +func TestDDBTimeToLive(t *testing.T) { + client, _ := newSuiteDDBEnv(t) + ctx := context.Background() + + suiteDDBCreateTable(t, client, "ttl-table", "pk", "") + + desc, err := client.DescribeTimeToLive(ctx, &dynamodb.DescribeTimeToLiveInput{ + TableName: aws.String("ttl-table"), + }) + require.NoError(t, err) + assert.Equal(t, ddbtypes.TimeToLiveStatusDisabled, desc.TimeToLiveDescription.TimeToLiveStatus) + + if _, err := client.UpdateTimeToLive(ctx, &dynamodb.UpdateTimeToLiveInput{ + TableName: aws.String("ttl-table"), + TimeToLiveSpecification: &ddbtypes.TimeToLiveSpecification{ + Enabled: aws.Bool(true), AttributeName: aws.String("expiresAt"), + }, + }); err != nil { + t.Fatalf("UpdateTimeToLive: %v", err) + } + + desc, err = client.DescribeTimeToLive(ctx, &dynamodb.DescribeTimeToLiveInput{ + TableName: aws.String("ttl-table"), + }) + require.NoError(t, err) + assert.Equal(t, ddbtypes.TimeToLiveStatusEnabled, desc.TimeToLiveDescription.TimeToLiveStatus) + assert.Equal(t, "expiresAt", aws.ToString(desc.TimeToLiveDescription.AttributeName)) +} + +// TestDDBQueryWithFilterExpression is a regression guard for issue #319: Query +// ignored FilterExpression and returned the full key-matched set (silent wrong +// data), while Scan applied it correctly. +func TestDDBQueryWithFilterExpression(t *testing.T) { + client, _ := newSuiteDDBEnv(t) + ctx := context.Background() + + suiteDDBCreateTable(t, client, "orders", "customer", "orderDate") + + for _, o := range []struct{ date, total string }{ + {"2024-01-01", "10"}, + {"2024-02-15", "70"}, + {"2024-03-10", "40"}, + } { + suiteDDBPut(t, client, "orders", map[string]ddbtypes.AttributeValue{ + "customer": sAttr("alice"), + "orderDate": sAttr(o.date), + "total": nAttr(o.total), + }) + } + + // Key matches 3 rows; the filter (total > 50) should leave only 1. + out, err := client.Query(ctx, &dynamodb.QueryInput{ + TableName: aws.String("orders"), + KeyConditionExpression: aws.String("customer = :c"), + FilterExpression: aws.String("total > :m"), + ExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":c": sAttr("alice"), ":m": nAttr("50"), + }, + }) + require.NoError(t, err) + require.Equal(t, int32(1), out.Count, "FilterExpression must prune the key-matched set") + assert.Equal(t, "70", attrN(t, out.Items[0], "total")) +} + // TestDDBQueryEdges: query on an empty table returns zero items; // query against a missing table or unknown index yields the typed error. func TestDDBQueryEdges(t *testing.T) { @@ -781,13 +883,9 @@ func TestDDBTypedErrors(t *testing.T) { }) t.Run("unrouted operation is UnknownOperationException", func(t *testing.T) { - // UpdateTimeToLive has no HTTP surface in the emulator. - _, err := client.UpdateTimeToLive(ctx, &dynamodb.UpdateTimeToLiveInput{ + // DescribeContinuousBackups has no HTTP surface in the emulator. + _, err := client.DescribeContinuousBackups(ctx, &dynamodb.DescribeContinuousBackupsInput{ TableName: aws.String("errs"), - TimeToLiveSpecification: &ddbtypes.TimeToLiveSpecification{ - AttributeName: aws.String("ttl"), - Enabled: aws.Bool(true), - }, }) require.Error(t, err) diff --git a/server/aws/dynamodb/handler.go b/server/aws/dynamodb/handler.go index 4cd56dfc..a704140d 100644 --- a/server/aws/dynamodb/handler.go +++ b/server/aws/dynamodb/handler.go @@ -36,7 +36,8 @@ func (*Handler) Matches(r *http.Request) bool { func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) - if h.routeTables(w, r, op) || h.routeItems(w, r, op) || h.routeBatch(w, r, op) { + if h.routeTables(w, r, op) || h.routeItems(w, r, op) || h.routeBatch(w, r, op) || + h.routeTags(w, r, op) || h.routeTTL(w, r, op) { return } @@ -363,6 +364,7 @@ func (h *Handler) query(w http.ResponseWriter, r *http.Request) { var req struct { TableName string `json:"TableName"` KeyConditionExpression string `json:"KeyConditionExpression"` + FilterExpression string `json:"FilterExpression"` ExpressionAttributeValues map[string]any `json:"ExpressionAttributeValues"` ExpressionAttributeNames map[string]string `json:"ExpressionAttributeNames"` Limit int `json:"Limit"` @@ -377,6 +379,7 @@ func (h *Handler) query(w http.ResponseWriter, r *http.Request) { vals := fromWireItem(req.ExpressionAttributeValues) kc := parseKeyCondition(req.KeyConditionExpression, vals, req.ExpressionAttributeNames) + filters := parseFilterExpression(req.FilterExpression, vals, req.ExpressionAttributeNames) forward := true if req.ScanIndexForward != nil { @@ -387,6 +390,7 @@ func (h *Handler) query(w http.ResponseWriter, r *http.Request) { Table: req.TableName, IndexName: req.IndexName, KeyCondition: kc, + Filters: filters, Limit: req.Limit, SortDescending: !forward, ExclusiveStartKey: fromWireItem(req.ExclusiveStartKey), diff --git a/server/aws/dynamodb/tags.go b/server/aws/dynamodb/tags.go new file mode 100644 index 00000000..dd87915a --- /dev/null +++ b/server/aws/dynamodb/tags.go @@ -0,0 +1,112 @@ +package dynamodb + +import ( + "net/http" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire" +) + +type tagJSON struct { + Key string `json:"Key"` + Value string `json:"Value"` +} + +// tableFromARN resolves a DynamoDB ResourceArn ("arn:aws:dynamodb:: +// :table/") to the bare table name the driver keys on. A value +// that isn't an ARN is returned unchanged, so a plain name also works. +func tableFromARN(arn string) string { + const marker = ":table/" + + if i := strings.LastIndex(arn, marker); i >= 0 { + name := arn[i+len(marker):] + // A table ARN may carry a sub-resource suffix (.../index/...); keep only + // the table segment. + if j := strings.IndexByte(name, '/'); j >= 0 { + name = name[:j] + } + + return name + } + + return arn +} + +func (h *Handler) routeTags(w http.ResponseWriter, r *http.Request, op string) bool { + switch op { + case "TagResource": + h.tagResource(w, r) + case "UntagResource": + h.untagResource(w, r) + case "ListTagsOfResource": + h.listTagsOfResource(w, r) + default: + return false + } + + return true +} + +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceArn string `json:"ResourceArn"` + Tags []tagJSON `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags := make(map[string]string, len(req.Tags)) + for _, t := range req.Tags { + tags[t.Key] = t.Value + } + + if err := h.db.TagResource(r.Context(), tableFromARN(req.ResourceArn), tags); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceArn string `json:"ResourceArn"` + TagKeys []string `json:"TagKeys"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := h.db.UntagResource(r.Context(), tableFromARN(req.ResourceArn), req.TagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) listTagsOfResource(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceArn string `json:"ResourceArn"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags, err := h.db.ListTagsOfResource(r.Context(), tableFromARN(req.ResourceArn)) + if err != nil { + writeErr(w, err) + return + } + + out := make([]tagJSON, 0, len(tags)) + for k, v := range tags { + out = append(out, tagJSON{Key: k, Value: v}) + } + + wire.WriteJSON(w, map[string]any{"Tags": out}) +} diff --git a/server/aws/dynamodb/ttl.go b/server/aws/dynamodb/ttl.go new file mode 100644 index 00000000..2c61c24c --- /dev/null +++ b/server/aws/dynamodb/ttl.go @@ -0,0 +1,78 @@ +package dynamodb + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire" + dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" +) + +func (h *Handler) routeTTL(w http.ResponseWriter, r *http.Request, op string) bool { + switch op { + case "UpdateTimeToLive": + h.updateTimeToLive(w, r) + case "DescribeTimeToLive": + h.describeTimeToLive(w, r) + default: + return false + } + + return true +} + +func (h *Handler) updateTimeToLive(w http.ResponseWriter, r *http.Request) { + var req struct { + TableName string `json:"TableName"` + TimeToLiveSpecification struct { + Enabled bool `json:"Enabled"` + AttributeName string `json:"AttributeName"` + } `json:"TimeToLiveSpecification"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + spec := req.TimeToLiveSpecification + + if err := h.db.UpdateTTL(r.Context(), req.TableName, dbdriver.TTLConfig{ + Enabled: spec.Enabled, AttributeName: spec.AttributeName, + }); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{ + "TimeToLiveSpecification": map[string]any{ + "Enabled": spec.Enabled, "AttributeName": spec.AttributeName, + }, + }) +} + +func (h *Handler) describeTimeToLive(w http.ResponseWriter, r *http.Request) { + var req struct { + TableName string `json:"TableName"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + cfg, err := h.db.DescribeTTL(r.Context(), req.TableName) + if err != nil { + writeErr(w, err) + return + } + + status := "DISABLED" + if cfg.Enabled { + status = "ENABLED" + } + + wire.WriteJSON(w, map[string]any{ + "TimeToLiveDescription": map[string]any{ + "TimeToLiveStatus": status, + "AttributeName": cfg.AttributeName, + }, + }) +} diff --git a/server/aws/ec2/client_vpn.go b/server/aws/ec2/client_vpn.go new file mode 100644 index 00000000..cfa19b49 --- /dev/null +++ b/server/aws/ec2/client_vpn.go @@ -0,0 +1,357 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + "strconv" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) clientVPN() (netdriver.ClientVPN, bool) { + c, ok := h.vpc.(netdriver.ClientVPN) + + return c, ok +} + +type clientVPNStatusXML struct { + Code string `xml:"code"` +} + +type clientVPNAuthXML struct { + Type string `xml:"type"` +} + +type clientVPNEndpointXML struct { + ClientVpnEndpointID string `xml:"clientVpnEndpointId"` + Description string `xml:"description,omitempty"` + Status clientVPNStatusXML `xml:"status"` + ClientCidrBlock string `xml:"clientCidrBlock"` + ServerCertificateARN string `xml:"serverCertificateArn"` + AuthenticationOptions []clientVPNAuthXML `xml:"authenticationOptions>item,omitempty"` + SplitTunnel bool `xml:"splitTunnel"` + VpcID string `xml:"vpcId,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +//nolint:gocyclo // flat action dispatch table +func (h *Handler) routeClientVPN(w http.ResponseWriter, r *http.Request, action string) bool { + c, ok := h.clientVPN() + if !ok { + return false + } + + switch action { + case "CreateClientVpnEndpoint": + h.createClientVPNEndpoint(w, r, c) + case "DeleteClientVpnEndpoint": + h.deleteClientVPNEndpoint(w, r, c) + case "DescribeClientVpnEndpoints": + h.describeClientVPNEndpoints(w, r, c) + case "AssociateClientVpnTargetNetwork": + h.associateClientVPN(w, r, c) + case "DisassociateClientVpnTargetNetwork": + h.disassociateClientVPN(w, r, c) + case "DescribeClientVpnTargetNetworks": + h.describeClientVPNTargetNetworks(w, r, c) + case "AuthorizeClientVpnIngress": + h.authorizeClientVPNIngress(w, r, c) + case "RevokeClientVpnIngress": + h.revokeClientVPNIngress(w, r, c) + case "DescribeClientVpnAuthorizationRules": + h.describeClientVPNAuthRules(w, r, c) + case "CreateClientVpnRoute": + h.createClientVPNRoute(w, r, c) + case "DeleteClientVpnRoute": + h.deleteClientVPNRoute(w, r, c) + case "DescribeClientVpnRoutes": + h.describeClientVPNRoutes(w, r, c) + default: + return false + } + + return true +} + +func (*Handler) createClientVPNEndpoint(w http.ResponseWriter, r *http.Request, c netdriver.ClientVPN) { + out, err := c.CreateClientVPNEndpoint(r.Context(), netdriver.ClientVPNEndpointConfig{ + Description: r.Form.Get("Description"), + ClientCIDRBlock: r.Form.Get("ClientCidrBlock"), + ServerCertificateARN: r.Form.Get("ServerCertificateArn"), + AuthenticationTypes: parseClientVPNAuthTypes(r), + SplitTunnel: r.Form.Get("SplitTunnel") == formTrue, + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "client-vpn-endpoint"), + }) + if err != nil { + writeClientVPNErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateClientVpnEndpointResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + EndpointID string `xml:"clientVpnEndpointId"` + Status clientVPNStatusXML `xml:"status"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, EndpointID: out.ID, Status: clientVPNStatusXML{Code: out.State}}) +} + +func (*Handler) deleteClientVPNEndpoint(w http.ResponseWriter, r *http.Request, c netdriver.ClientVPN) { + if err := c.DeleteClientVPNEndpoint(r.Context(), r.Form.Get("ClientVpnEndpointId")); err != nil { + writeClientVPNErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteClientVpnEndpointResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Status clientVPNStatusXML `xml:"status"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Status: clientVPNStatusXML{Code: "deleting"}}) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeClientVPNEndpoints(w http.ResponseWriter, r *http.Request, c netdriver.ClientVPN) { + items, err := c.DescribeClientVPNEndpoints(r.Context(), awsquery.ListStrings(r.Form, "ClientVpnEndpointId")) + if err != nil { + writeClientVPNErr(w, err) + return + } + + out := make([]clientVPNEndpointXML, 0, len(items)) + for i := range items { + out = append(out, toClientVPNEndpointXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeClientVpnEndpointsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []clientVPNEndpointXML `xml:"clientVpnEndpoint>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) associateClientVPN(w http.ResponseWriter, r *http.Request, c netdriver.ClientVPN) { + out, err := c.AssociateClientVPNTargetNetwork(r.Context(), r.Form.Get("ClientVpnEndpointId"), r.Form.Get("SubnetId")) + if err != nil { + writeClientVPNErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"AssociateClientVpnTargetNetworkResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + AssociationID string `xml:"associationId"` + Status clientVPNStatusXML `xml:"status"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, AssociationID: out.AssociationID, Status: clientVPNStatusXML{Code: out.State}}) +} + +func (*Handler) disassociateClientVPN(w http.ResponseWriter, r *http.Request, c netdriver.ClientVPN) { + if err := c.DisassociateClientVPNTargetNetwork(r.Context(), r.Form.Get("ClientVpnEndpointId"), r.Form.Get("AssociationId")); err != nil { + writeClientVPNErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DisassociateClientVpnTargetNetworkResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Status clientVPNStatusXML `xml:"status"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Status: clientVPNStatusXML{Code: "disassociating"}}) +} + +type clientVPNTargetNetworkXML struct { + AssociationID string `xml:"associationId"` + ClientVpnEndpointID string `xml:"clientVpnEndpointId"` + TargetNetworkID string `xml:"targetNetworkId"` + VpcID string `xml:"vpcId,omitempty"` + Status clientVPNStatusXML `xml:"status"` +} + +type clientVPNAuthRuleXML struct { + ClientVpnEndpointID string `xml:"clientVpnEndpointId"` + DestinationCidr string `xml:"destinationCidr"` + GroupID string `xml:"groupId,omitempty"` + AccessAll bool `xml:"accessAll"` + Status clientVPNStatusXML `xml:"status"` +} + +type clientVPNRouteXML struct { + ClientVpnEndpointID string `xml:"clientVpnEndpointId"` + DestinationCidr string `xml:"destinationCidr"` + TargetSubnet string `xml:"targetSubnet"` + Status clientVPNStatusXML `xml:"status"` +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeClientVPNTargetNetworks(w http.ResponseWriter, r *http.Request, c netdriver.ClientVPN) { + items, err := c.DescribeClientVPNTargetNetworks(r.Context(), r.Form.Get("ClientVpnEndpointId")) + if err != nil { + writeClientVPNErr(w, err) + return + } + + out := make([]clientVPNTargetNetworkXML, 0, len(items)) + for i := range items { + out = append(out, clientVPNTargetNetworkXML{ + AssociationID: items[i].AssociationID, ClientVpnEndpointID: items[i].EndpointID, + TargetNetworkID: items[i].SubnetID, VpcID: items[i].VPCID, + Status: clientVPNStatusXML{Code: items[i].State}, + }) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeClientVpnTargetNetworksResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []clientVPNTargetNetworkXML `xml:"clientVpnTargetNetworks>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) authorizeClientVPNIngress(w http.ResponseWriter, r *http.Request, c netdriver.ClientVPN) { + out, err := c.AuthorizeClientVPNIngress(r.Context(), r.Form.Get("ClientVpnEndpointId"), + r.Form.Get("TargetNetworkCidr"), r.Form.Get("AccessGroupId"), r.Form.Get("AuthorizeAllGroups") == formTrue) + if err != nil { + writeClientVPNErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"AuthorizeClientVpnIngressResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Status clientVPNStatusXML `xml:"status"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Status: clientVPNStatusXML{Code: out.Status}}) +} + +func (*Handler) revokeClientVPNIngress(w http.ResponseWriter, r *http.Request, c netdriver.ClientVPN) { + err := c.RevokeClientVPNIngress(r.Context(), r.Form.Get("ClientVpnEndpointId"), r.Form.Get("TargetNetworkCidr")) + if err != nil { + writeClientVPNErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"RevokeClientVpnIngressResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Status clientVPNStatusXML `xml:"status"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Status: clientVPNStatusXML{Code: "revoking"}}) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeClientVPNAuthRules(w http.ResponseWriter, r *http.Request, c netdriver.ClientVPN) { + items, err := c.DescribeClientVPNAuthorizationRules(r.Context(), r.Form.Get("ClientVpnEndpointId")) + if err != nil { + writeClientVPNErr(w, err) + return + } + + out := make([]clientVPNAuthRuleXML, 0, len(items)) + for i := range items { + out = append(out, clientVPNAuthRuleXML{ + ClientVpnEndpointID: items[i].EndpointID, DestinationCidr: items[i].TargetCIDR, + GroupID: items[i].GroupID, AccessAll: items[i].AccessAll, + Status: clientVPNStatusXML{Code: items[i].Status}, + }) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeClientVpnAuthorizationRulesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []clientVPNAuthRuleXML `xml:"authorizationRule>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) createClientVPNRoute(w http.ResponseWriter, r *http.Request, c netdriver.ClientVPN) { + out, err := c.CreateClientVPNRoute(r.Context(), r.Form.Get("ClientVpnEndpointId"), + r.Form.Get("DestinationCidrBlock"), r.Form.Get("TargetVpcSubnetId")) + if err != nil { + writeClientVPNErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateClientVpnRouteResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Status clientVPNStatusXML `xml:"status"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Status: clientVPNStatusXML{Code: out.Status}}) +} + +func (*Handler) deleteClientVPNRoute(w http.ResponseWriter, r *http.Request, c netdriver.ClientVPN) { + err := c.DeleteClientVPNRoute(r.Context(), r.Form.Get("ClientVpnEndpointId"), + r.Form.Get("DestinationCidrBlock"), r.Form.Get("TargetVpcSubnetId")) + if err != nil { + writeClientVPNErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteClientVpnRouteResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Status clientVPNStatusXML `xml:"status"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Status: clientVPNStatusXML{Code: "deleting"}}) +} + +func (*Handler) describeClientVPNRoutes(w http.ResponseWriter, r *http.Request, c netdriver.ClientVPN) { + items, err := c.DescribeClientVPNRoutes(r.Context(), r.Form.Get("ClientVpnEndpointId")) + if err != nil { + writeClientVPNErr(w, err) + return + } + + out := make([]clientVPNRouteXML, 0, len(items)) + for i := range items { + out = append(out, clientVPNRouteXML{ + ClientVpnEndpointID: items[i].EndpointID, DestinationCidr: items[i].DestinationCIDR, + TargetSubnet: items[i].TargetSubnetID, Status: clientVPNStatusXML{Code: items[i].Status}, + }) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeClientVpnRoutesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []clientVPNRouteXML `xml:"routes>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func toClientVPNEndpointXML(e *netdriver.ClientVPNEndpoint) clientVPNEndpointXML { + x := clientVPNEndpointXML{ + ClientVpnEndpointID: e.ID, Description: e.Description, Status: clientVPNStatusXML{Code: e.State}, + ClientCidrBlock: e.ClientCIDRBlock, ServerCertificateARN: e.ServerCertificateARN, + SplitTunnel: e.SplitTunnel, VpcID: e.VPCID, Tags: toTagItems(e.Tags), + } + + for _, t := range e.AuthenticationTypes { + x.AuthenticationOptions = append(x.AuthenticationOptions, clientVPNAuthXML{Type: t}) + } + + return x +} + +// parseClientVPNAuthTypes reads the Authentication.N.Type list from the EC2 +// query form (the SDK serializes AuthenticationOptions as Authentication.N). +func parseClientVPNAuthTypes(r *http.Request) []string { + var out []string + + for i := 1; ; i++ { + t := r.Form.Get("Authentication." + strconv.Itoa(i) + ".Type") + if t == "" { + break + } + + out = append(out, t) + } + + return out +} + +func writeClientVPNErr(w http.ResponseWriter, err error) { + writeErrWithNotFound(w, err, "InvalidClientVpnEndpointId.NotFound", "IncorrectState") +} diff --git a/server/aws/ec2/dhcp_options.go b/server/aws/ec2/dhcp_options.go new file mode 100644 index 00000000..692d71a5 --- /dev/null +++ b/server/aws/ec2/dhcp_options.go @@ -0,0 +1,157 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + "sort" + "strconv" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) dhcpOptionSets() (netdriver.DHCPOptionSets, bool) { + d, ok := h.vpc.(netdriver.DHCPOptionSets) + + return d, ok +} + +type dhcpConfigValueXML struct { + Value string `xml:"value"` +} + +type dhcpConfigXML struct { + Key string `xml:"key"` + Values []dhcpConfigValueXML `xml:"valueSet>item"` +} + +type dhcpOptionsXML struct { + DhcpOptionsID string `xml:"dhcpOptionsId"` + DhcpConfigurations []dhcpConfigXML `xml:"dhcpConfigurationSet>item,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +func (h *Handler) routeDHCPOptions(w http.ResponseWriter, r *http.Request, action string) bool { + d, ok := h.dhcpOptionSets() + if !ok { + return false + } + + switch action { + case "CreateDhcpOptions": + h.createDHCPOptions(w, r, d) + case "DeleteDhcpOptions": + h.deleteDHCPOptions(w, r, d) + case "DescribeDhcpOptions": + h.describeDHCPOptions(w, r, d) + case "AssociateDhcpOptions": + h.associateDHCPOptions(w, r, d) + default: + return false + } + + return true +} + +func (*Handler) createDHCPOptions(w http.ResponseWriter, r *http.Request, d netdriver.DHCPOptionSets) { + out, err := d.CreateDHCPOptions(r.Context(), netdriver.DHCPOptionsConfig{ + Configuration: parseDHCPConfigurations(r), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "dhcp-options"), + }) + if err != nil { + writeDHCPErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateDhcpOptionsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Opts dhcpOptionsXML `xml:"dhcpOptions"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Opts: toDHCPOptionsXML(out)}) +} + +func (*Handler) deleteDHCPOptions(w http.ResponseWriter, r *http.Request, d netdriver.DHCPOptionSets) { + if err := d.DeleteDHCPOptions(r.Context(), r.Form.Get("DhcpOptionsId")); err != nil { + writeDHCPErr(w, err) + return + } + + writeReturnTrue(w, "DeleteDhcpOptionsResponse") +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeDHCPOptions(w http.ResponseWriter, r *http.Request, d netdriver.DHCPOptionSets) { + items, err := d.DescribeDHCPOptions(r.Context(), awsquery.ListStrings(r.Form, "DhcpOptionsId")) + if err != nil { + writeDHCPErr(w, err) + return + } + + out := make([]dhcpOptionsXML, 0, len(items)) + for i := range items { + out = append(out, toDHCPOptionsXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeDhcpOptionsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []dhcpOptionsXML `xml:"dhcpOptionsSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) associateDHCPOptions(w http.ResponseWriter, r *http.Request, d netdriver.DHCPOptionSets) { + if err := d.AssociateDHCPOptions(r.Context(), r.Form.Get("DhcpOptionsId"), r.Form.Get("VpcId")); err != nil { + writeDHCPErr(w, err) + return + } + + writeReturnTrue(w, "AssociateDhcpOptionsResponse") +} + +// parseDHCPConfigurations reads DhcpConfiguration.N.Key + .Value.M groups. +func parseDHCPConfigurations(r *http.Request) map[string][]string { + out := map[string][]string{} + + for i := 1; ; i++ { + key := r.Form.Get("DhcpConfiguration." + strconv.Itoa(i) + ".Key") + if key == "" { + break + } + + out[key] = awsquery.ListStrings(r.Form, "DhcpConfiguration."+strconv.Itoa(i)+".Value") + } + + if len(out) == 0 { + return nil + } + + return out +} + +func toDHCPOptionsXML(d *netdriver.DHCPOptions) dhcpOptionsXML { + keys := make([]string, 0, len(d.Configuration)) + for k := range d.Configuration { + keys = append(keys, k) + } + + sort.Strings(keys) + + cfgs := make([]dhcpConfigXML, 0, len(keys)) + + for _, k := range keys { + vals := make([]dhcpConfigValueXML, 0, len(d.Configuration[k])) + for _, v := range d.Configuration[k] { + vals = append(vals, dhcpConfigValueXML{Value: v}) + } + + cfgs = append(cfgs, dhcpConfigXML{Key: k, Values: vals}) + } + + return dhcpOptionsXML{DhcpOptionsID: d.ID, DhcpConfigurations: cfgs, Tags: toTagItems(d.Tags)} +} + +func writeDHCPErr(w http.ResponseWriter, err error) { + writeErrWithNotFound(w, err, "InvalidDhcpOptionID.NotFound", "DependencyViolation") +} diff --git a/server/aws/ec2/ec2_phase2_test.go b/server/aws/ec2/ec2_phase2_test.go index b5be232f..1b205300 100644 --- a/server/aws/ec2/ec2_phase2_test.go +++ b/server/aws/ec2/ec2_phase2_test.go @@ -480,6 +480,124 @@ func TestToIPPermissionXMLsEmpty(t *testing.T) { } // between returns the substring between open and close markers, or empty. +// TestCreateAndDeleteTags is a regression guard for issue #319: EC2 +// CreateTags/DeleteTags returned InvalidAction. Tags must apply to VPC-family +// resources (networking provider) and compute resources (compute tagger), and +// an unknown ID must yield InvalidID.NotFound. +func TestCreateAndDeleteTags(t *testing.T) { + h := newFullHandler() + + vpc := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"CreateVpc"}, "CidrBlock": {"10.0.0.0/16"}, + }) + vpcID := between(vpc.Body.String(), "", "") + + if vpcID == "" { + t.Fatalf("CreateVpc returned no id: %s", vpc.Body.String()) + } + + // CreateTags on the VPC. + ct := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"CreateTags"}, "ResourceId.1": {vpcID}, + "Tag.1.Key": {"env"}, "Tag.1.Value": {"prod"}, + "Tag.2.Key": {"team"}, "Tag.2.Value": {"platform"}, + }) + if ct.Code != http.StatusOK { + t.Fatalf("CreateTags status = %d: %s", ct.Code, ct.Body.String()) + } + + desc := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"DescribeVpcs"}, "VpcId.1": {vpcID}, + }).Body.String() + if !strings.Contains(desc, "env") || !strings.Contains(desc, "team") { + t.Fatalf("tags missing after CreateTags: %s", desc) + } + + // DeleteTags removes one key. + if dt := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"DeleteTags"}, "ResourceId.1": {vpcID}, "Tag.1.Key": {"env"}, + }); dt.Code != http.StatusOK { + t.Fatalf("DeleteTags status = %d", dt.Code) + } + + desc = do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"DescribeVpcs"}, "VpcId.1": {vpcID}, + }).Body.String() + if strings.Contains(desc, "env") || !strings.Contains(desc, "team") { + t.Fatalf("DeleteTags result wrong: %s", desc) + } + + // Unknown ID -> InvalidID.NotFound. + bad := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"CreateTags"}, "ResourceId.1": {"vpc-deadbeef"}, + "Tag.1.Key": {"a"}, "Tag.1.Value": {"b"}, + }) + if !strings.Contains(bad.Body.String(), "InvalidID.NotFound") { + t.Fatalf("want InvalidID.NotFound, got: %s", bad.Body.String()) + } +} + +// TestCreateNetworkInterfaceAndInstanceStatus is a regression guard for issue +// #319: CreateNetworkInterface, MonitorInstances, and DescribeInstanceStatus +// returned InvalidAction. +func TestCreateNetworkInterfaceAndInstanceStatus(t *testing.T) { + h := newFullHandler() + + vpcID := between(do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"CreateVpc"}, "CidrBlock": {"10.0.0.0/16"}, + }).Body.String(), "", "") + + subnetID := between(do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"CreateSubnet"}, "VpcId": {vpcID}, "CidrBlock": {"10.0.1.0/24"}, + }).Body.String(), "", "") + + // CreateNetworkInterface in the subnet. + eni := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"CreateNetworkInterface"}, "SubnetId": {subnetID}, "Description": {"eni-x"}, + }) + if eni.Code != http.StatusOK || !strings.Contains(eni.Body.String(), "eni-") { + t.Fatalf("CreateNetworkInterface: code=%d body=%s", eni.Code, eni.Body.String()) + } + + // Run an instance, then monitor + status it. + instID := between(do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"RunInstances"}, "ImageId": {"ami-1"}, "InstanceType": {"t3.micro"}, + "MinCount": {"1"}, "MaxCount": {"1"}, + }).Body.String(), "", "") + + mon := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"MonitorInstances"}, "InstanceId.1": {instID}, + }) + if mon.Code != http.StatusOK || !strings.Contains(mon.Body.String(), "enabled") { + t.Fatalf("MonitorInstances: code=%d body=%s", mon.Code, mon.Body.String()) + } + + status := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"DescribeInstanceStatus"}, "InstanceId.1": {instID}, + }) + if status.Code != http.StatusOK || !strings.Contains(status.Body.String(), ""+instID+"") { + t.Fatalf("DescribeInstanceStatus: code=%d body=%s", status.Code, status.Body.String()) + } +} + +// TestCreateNetworkInterfaceUnknownSubnet guards the resolve-from-subnet path: +// an ENI create against a subnet that does not exist must fail (NotFound), not +// silently create an interface with a dangling subnet reference. +func TestCreateNetworkInterfaceUnknownSubnet(t *testing.T) { + h := newFullHandler() + + resp := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"CreateNetworkInterface"}, "SubnetId": {"subnet-does-not-exist"}, + }) + if resp.Code == http.StatusOK { + t.Fatalf("want error for unknown subnet, got 200: %s", resp.Body.String()) + } + + if !strings.Contains(resp.Body.String(), "InvalidSubnetID.NotFound") { + t.Fatalf("want InvalidSubnetID.NotFound, got code=%d body=%s", resp.Code, resp.Body.String()) + } +} + func between(s, open, close string) string { i := strings.Index(s, open) if i < 0 { diff --git a/server/aws/ec2/ec2_test.go b/server/aws/ec2/ec2_test.go index 0ded106f..4aac9279 100644 --- a/server/aws/ec2/ec2_test.go +++ b/server/aws/ec2/ec2_test.go @@ -99,6 +99,31 @@ func TestMatchesRejectsJSONPost(t *testing.T) { } } +// TestDescribeRegionsAndInstanceTypes is a regression guard for issue #319: +// DescribeRegions / DescribeInstanceTypes returned InvalidAction, breaking +// region/instance-type validation calls. +func TestDescribeRegionsAndInstanceTypes(t *testing.T) { + h := newHandler() + + regions := do(t, h, http.MethodPost, "/", url.Values{"Action": {"DescribeRegions"}}) + if regions.Code != http.StatusOK { + t.Fatalf("DescribeRegions status = %d", regions.Code) + } + if !strings.Contains(regions.Body.String(), "us-east-1") { + t.Fatalf("DescribeRegions missing us-east-1: %s", regions.Body.String()) + } + + types := do(t, h, http.MethodPost, "/", url.Values{ + "Action": {"DescribeInstanceTypes"}, "InstanceType.1": {"t3.micro"}, + }) + if types.Code != http.StatusOK { + t.Fatalf("DescribeInstanceTypes status = %d", types.Code) + } + if !strings.Contains(types.Body.String(), "t3.micro") { + t.Fatalf("DescribeInstanceTypes missing t3.micro: %s", types.Body.String()) + } +} + func TestServeHTTPUnknownActionReturns400(t *testing.T) { h := newHandler() diff --git a/server/aws/ec2/egress_only_igw.go b/server/aws/ec2/egress_only_igw.go new file mode 100644 index 00000000..e7b2a5fc --- /dev/null +++ b/server/aws/ec2/egress_only_igw.go @@ -0,0 +1,110 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) egressOnlyIGWs() (netdriver.EgressOnlyInternetGateways, bool) { + e, ok := h.vpc.(netdriver.EgressOnlyInternetGateways) + + return e, ok +} + +type egressOnlyIGWAttachmentXML struct { + VpcID string `xml:"vpcId"` + State string `xml:"state"` +} + +type egressOnlyIGWXML struct { + EgressOnlyInternetGatewayID string `xml:"egressOnlyInternetGatewayId"` + Attachments []egressOnlyIGWAttachmentXML `xml:"attachmentSet>item,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +func (h *Handler) routeEgressOnlyIGW(w http.ResponseWriter, r *http.Request, action string) bool { + e, ok := h.egressOnlyIGWs() + if !ok { + return false + } + + switch action { + case "CreateEgressOnlyInternetGateway": + h.createEgressOnlyIGW(w, r, e) + case "DeleteEgressOnlyInternetGateway": + h.deleteEgressOnlyIGW(w, r, e) + case "DescribeEgressOnlyInternetGateways": + h.describeEgressOnlyIGWs(w, r, e) + default: + return false + } + + return true +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) createEgressOnlyIGW(w http.ResponseWriter, r *http.Request, e netdriver.EgressOnlyInternetGateways) { + out, err := e.CreateEgressOnlyInternetGateway(r.Context(), r.Form.Get("VpcId"), + mergeTagSpecs(awsquery.TagSpecs(r.Form), "egress-only-internet-gateway")) + if err != nil { + writeEgressOnlyErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateEgressOnlyInternetGatewayResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Gateway egressOnlyIGWXML `xml:"egressOnlyInternetGateway"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Gateway: toEgressOnlyIGWXML(out)}) +} + +func (*Handler) deleteEgressOnlyIGW(w http.ResponseWriter, r *http.Request, e netdriver.EgressOnlyInternetGateways) { + if err := e.DeleteEgressOnlyInternetGateway(r.Context(), r.Form.Get("EgressOnlyInternetGatewayId")); err != nil { + writeEgressOnlyErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteEgressOnlyInternetGatewayResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + ReturnCode bool `xml:"returnCode"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, ReturnCode: true}) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeEgressOnlyIGWs(w http.ResponseWriter, r *http.Request, e netdriver.EgressOnlyInternetGateways) { + items, err := e.DescribeEgressOnlyInternetGateways(r.Context(), awsquery.ListStrings(r.Form, "EgressOnlyInternetGatewayId")) + if err != nil { + writeEgressOnlyErr(w, err) + return + } + + out := make([]egressOnlyIGWXML, 0, len(items)) + for i := range items { + out = append(out, toEgressOnlyIGWXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeEgressOnlyInternetGatewaysResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []egressOnlyIGWXML `xml:"egressOnlyInternetGatewaySet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func toEgressOnlyIGWXML(e *netdriver.EgressOnlyInternetGateway) egressOnlyIGWXML { + return egressOnlyIGWXML{ + EgressOnlyInternetGatewayID: e.ID, + Attachments: []egressOnlyIGWAttachmentXML{{VpcID: e.AttachedVPCID, State: e.State}}, + Tags: toTagItems(e.Tags), + } +} + +func writeEgressOnlyErr(w http.ResponseWriter, err error) { + writeErrWithNotFound(w, err, "InvalidEgressOnlyInternetGatewayId.NotFound", "DependencyViolation") +} diff --git a/server/aws/ec2/endpoint_service.go b/server/aws/ec2/endpoint_service.go new file mode 100644 index 00000000..d3412807 --- /dev/null +++ b/server/aws/ec2/endpoint_service.go @@ -0,0 +1,169 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) endpointServices() (netdriver.VPCEndpointServices, bool) { + s, ok := h.vpc.(netdriver.VPCEndpointServices) + + return s, ok +} + +type endpointServiceXML struct { + ServiceID string `xml:"serviceId"` + ServiceName string `xml:"serviceName"` + ServiceState string `xml:"serviceState"` + AcceptanceRequired bool `xml:"acceptanceRequired"` + AvailabilityZones []string `xml:"availabilityZoneSet>item,omitempty"` + NlbArns []string `xml:"networkLoadBalancerArnSet>item,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +func (h *Handler) routeEndpointServices(w http.ResponseWriter, r *http.Request, action string) bool { + s, ok := h.endpointServices() + if !ok { + return false + } + + switch action { + case "CreateVpcEndpointServiceConfiguration": + h.createEndpointService(w, r, s) + case "DeleteVpcEndpointServiceConfigurations": + h.deleteEndpointService(w, r, s) + case "DescribeVpcEndpointServiceConfigurations": + h.describeEndpointServices(w, r, s) + case "ModifyVpcEndpointServicePermissions": + h.modifyEndpointServicePermissions(w, r, s) + case "DescribeVpcEndpointServicePermissions": + h.describeEndpointServicePermissions(w, r, s) + default: + return false + } + + return true +} + +func (*Handler) createEndpointService(w http.ResponseWriter, r *http.Request, s netdriver.VPCEndpointServices) { + out, err := s.CreateVPCEndpointServiceConfiguration(r.Context(), netdriver.EndpointServiceConfig{ + NetworkLoadBalancerARNs: awsquery.ListStrings(r.Form, "NetworkLoadBalancerArn"), + AcceptanceRequired: r.Form.Get("AcceptanceRequired") == formTrue, + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "vpc-endpoint-service"), + }) + if err != nil { + writeEndpointServiceErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateVpcEndpointServiceConfigurationResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Config endpointServiceXML `xml:"serviceConfiguration"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Config: toEndpointServiceXML(out)}) +} + +func (*Handler) deleteEndpointService(w http.ResponseWriter, r *http.Request, s netdriver.VPCEndpointServices) { + for _, id := range awsquery.ListStrings(r.Form, "ServiceId") { + if err := s.DeleteVPCEndpointServiceConfiguration(r.Context(), id); err != nil { + writeEndpointServiceErr(w, err) + return + } + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteVpcEndpointServiceConfigurationsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Unsucc []unsuccessfulItemXML `xml:"unsuccessful>item,omitempty"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID}) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeEndpointServices(w http.ResponseWriter, r *http.Request, s netdriver.VPCEndpointServices) { + items, err := s.DescribeVPCEndpointServiceConfigurations(r.Context(), awsquery.ListStrings(r.Form, "ServiceId")) + if err != nil { + writeEndpointServiceErr(w, err) + return + } + + out := make([]endpointServiceXML, 0, len(items)) + for i := range items { + out = append(out, toEndpointServiceXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeVpcEndpointServiceConfigurationsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []endpointServiceXML `xml:"serviceConfigurationSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) modifyEndpointServicePermissions(w http.ResponseWriter, r *http.Request, s netdriver.VPCEndpointServices) { + err := s.ModifyVPCEndpointServicePermissions(r.Context(), r.Form.Get("ServiceId"), + awsquery.ListStrings(r.Form, "AddAllowedPrincipals"), awsquery.ListStrings(r.Form, "RemoveAllowedPrincipals")) + if err != nil { + writeEndpointServiceErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"ModifyVpcEndpointServicePermissionsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + ReturnValue bool `xml:"return"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, ReturnValue: true}) +} + +func (*Handler) describeEndpointServicePermissions(w http.ResponseWriter, r *http.Request, s netdriver.VPCEndpointServices) { + principals, err := s.DescribeVPCEndpointServicePermissions(r.Context(), r.Form.Get("ServiceId")) + if err != nil { + writeEndpointServiceErr(w, err) + return + } + + type principalXML struct { + PrincipalType string `xml:"principalType"` + Principal string `xml:"principal"` + } + + out := make([]principalXML, 0, len(principals)) + for _, p := range principals { + out = append(out, principalXML{PrincipalType: "Account", Principal: p}) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeVpcEndpointServicePermissionsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Principals []principalXML `xml:"allowedPrincipals>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Principals: out}) +} + +func toEndpointServiceXML(s *netdriver.EndpointService) endpointServiceXML { + return endpointServiceXML{ + ServiceID: s.ID, ServiceName: s.ServiceName, ServiceState: s.State, + AcceptanceRequired: s.AcceptanceRequired, AvailabilityZones: s.AvailabilityZones, + NlbArns: s.NetworkLoadBalancerARNs, Tags: toTagItems(s.Tags), + } +} + +// unsuccessfulItemXML mirrors the EC2 UnsuccessfulItem shape (resourceId + +// nested error). The mock never fails a delete, so the set is always empty, +// but the shape must match what the SDK expects to deserialize. +type unsuccessfulItemXML struct { + ResourceID string `xml:"resourceId"` + Error struct { + Code string `xml:"code"` + Message string `xml:"message"` + } `xml:"error"` +} + +func writeEndpointServiceErr(w http.ResponseWriter, err error) { + writeErrWithNotFound(w, err, "InvalidVpcEndpointServiceId.NotFound", "IncorrectState") +} diff --git a/server/aws/ec2/handler.go b/server/aws/ec2/handler.go index 706e74d3..339d83ee 100644 --- a/server/aws/ec2/handler.go +++ b/server/aws/ec2/handler.go @@ -87,7 +87,26 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.routeVpcPeering, h.routeFlowLogs, h.routeNetworkACLs, + h.routeTransitGateways, + h.routeVPN, + h.routeDHCPOptions, + h.routePrefixLists, + h.routeEgressOnlyIGW, + h.routeEndpointServices, + h.routeClientVPN, + h.routeIPAM, + h.routeIPAMResources, + h.routeIPAMDiscovery, + h.routeIPAMByoip, + h.routeIPAMResolver, + h.routeIPAMPolicy, + h.routeTrafficMirroring, + h.routeNetworkInsights, + h.routeVPCBlockPublicAccess, h.routeVPC, + h.routeTags, + h.routeMetadata, + h.routeInstanceStatus, } for _, route := range routes { if route(w, r, action) { @@ -225,7 +244,6 @@ func (h *Handler) routeLaunchTemplates(w http.ResponseWriter, r *http.Request, a return true } -//nolint:dupl // action-dispatch switch; every route* function has this shape by design func (h *Handler) routeAutoScaling(w http.ResponseWriter, r *http.Request, action string) bool { switch action { case "CreateAutoScalingGroup": @@ -436,6 +454,8 @@ func (h *Handler) routeVPCRouteTable(w http.ResponseWriter, r *http.Request, act h.associateRouteTable(w, r) case "DisassociateRouteTable": h.disassociateRouteTable(w, r) + case "CreateNetworkInterface": + h.createNetworkInterface(w, r) case "DescribeNetworkInterfaces": h.describeNetworkInterfaces(w, r) case "DetachNetworkInterface": diff --git a/server/aws/ec2/instance_status.go b/server/aws/ec2/instance_status.go new file mode 100644 index 00000000..073fb94e --- /dev/null +++ b/server/aws/ec2/instance_status.go @@ -0,0 +1,127 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + computedriver "github.com/stackshy/cloudemu/v2/services/compute/driver" +) + +func (h *Handler) routeInstanceStatus(w http.ResponseWriter, r *http.Request, action string) bool { + switch action { + case "MonitorInstances": + h.monitorInstances(w, r, "enabled") + case "UnmonitorInstances": + h.monitorInstances(w, r, "disabled") + case "DescribeInstanceStatus": + h.describeInstanceStatus(w, r) + default: + return false + } + + return true +} + +type monitorItemXML struct { + InstanceID string `xml:"instanceId"` + State string `xml:"monitoring>state"` +} + +type monitorInstancesResponseXML struct { + XMLName xml.Name `xml:"MonitorInstancesResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + Instances []monitorItemXML `xml:"instancesSet>item"` +} + +// monitorInstances answers Monitor/UnmonitorInstances. It validates each +// requested instance exists (InvalidInstanceID.NotFound otherwise) and echoes +// the resulting monitoring state. +func (h *Handler) monitorInstances(w http.ResponseWriter, r *http.Request, state string) { + ids := awsquery.ListStrings(r.Form, "InstanceId") + + if _, err := h.compute.DescribeInstances(r.Context(), ids, nil); err != nil { + writeErr(w, err) + return + } + + items := make([]monitorItemXML, 0, len(ids)) + for _, id := range ids { + items = append(items, monitorItemXML{InstanceID: id, State: state}) + } + + awsquery.WriteXMLResponse(w, monitorInstancesResponseXML{ + Xmlns: awsquery.Namespace, RequestID: awsquery.RequestID, Instances: items, + }) +} + +type statusDetailXML struct { + Status string `xml:"status"` +} + +type instanceStatusItemXML struct { + InstanceID string `xml:"instanceId"` + AvailZone string `xml:"availabilityZone,omitempty"` + InstanceState instanceState `xml:"instanceState"` + SystemStatus statusDetailXML `xml:"systemStatus"` + InstanceStatus statusDetailXML `xml:"instanceStatus"` +} + +type describeInstanceStatusResponseXML struct { + XMLName xml.Name `xml:"DescribeInstanceStatusResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + Statuses []instanceStatusItemXML `xml:"instanceStatusSet>item"` +} + +// describeInstanceStatus answers DescribeInstanceStatus. By default only +// running instances are reported (matching real EC2); IncludeAllInstances=true +// reports every state. Running instances report passing system/instance checks. +func (h *Handler) describeInstanceStatus(w http.ResponseWriter, r *http.Request) { + ids := awsquery.ListStrings(r.Form, "InstanceId") + includeAll := r.Form.Get("IncludeAllInstances") == formTrue + + instances, err := h.compute.DescribeInstances(r.Context(), ids, nil) + if err != nil { + writeErr(w, err) + return + } + + out := make([]instanceStatusItemXML, 0, len(instances)) + + for i := range instances { + inst := &instances[i] + if !includeAll && inst.State != stateRunning { + continue + } + + out = append(out, statusItem(inst)) + } + + awsquery.WriteXMLResponse(w, describeInstanceStatusResponseXML{ + Xmlns: awsquery.Namespace, RequestID: awsquery.RequestID, Statuses: out, + }) +} + +func statusItem(inst *computedriver.Instance) instanceStatusItemXML { + // Checks are "ok" only once the instance is running; otherwise + // "not-applicable", matching real EC2's status-check semantics. + check := "not-applicable" + if inst.State == stateRunning { + check = "ok" + } + + az := "" + if len(inst.Zones) > 0 { + az = inst.Zones[0] + } + + return instanceStatusItemXML{ + InstanceID: inst.ID, + AvailZone: az, + InstanceState: instanceState{Code: stateCode(inst.State), Name: inst.State}, + SystemStatus: statusDetailXML{Status: check}, + InstanceStatus: statusDetailXML{Status: check}, + } +} diff --git a/server/aws/ec2/ipam.go b/server/aws/ec2/ipam.go new file mode 100644 index 00000000..26e73af5 --- /dev/null +++ b/server/aws/ec2/ipam.go @@ -0,0 +1,523 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + "strconv" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) ipam() (netdriver.IPAM, bool) { + i, ok := h.vpc.(netdriver.IPAM) + + return i, ok +} + +type ipamXML struct { + IpamID string `xml:"ipamId"` + IpamArn string `xml:"ipamArn"` + IpamRegion string `xml:"ipamRegion,omitempty"` + PublicDefaultScopeID string `xml:"publicDefaultScopeId"` + PrivateDefaultScopeID string `xml:"privateDefaultScopeId"` + ScopeCount int `xml:"scopeCount"` + DefaultResourceDiscoveryID string `xml:"defaultResourceDiscoveryId,omitempty"` + DefaultResourceDiscoveryAssociationID string `xml:"defaultResourceDiscoveryAssociationId,omitempty"` + ResourceDiscoveryAssociationCount int `xml:"resourceDiscoveryAssociationCount,omitempty"` + OperatingRegions []opRegXML `xml:"operatingRegionSet>item,omitempty"` + Description string `xml:"description,omitempty"` + Tier string `xml:"tier,omitempty"` + State string `xml:"state"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type opRegXML struct { + RegionName string `xml:"regionName"` +} + +type ipamScopeXML struct { + IpamScopeID string `xml:"ipamScopeId"` + IpamScopeArn string `xml:"ipamScopeArn"` + IpamArn string `xml:"ipamArn"` + IpamScopeType string `xml:"ipamScopeType"` + IsDefault bool `xml:"isDefault"` + PoolCount int `xml:"poolCount"` + Description string `xml:"description,omitempty"` + State string `xml:"state"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type ipamPoolXML struct { + IpamPoolID string `xml:"ipamPoolId"` + IpamPoolArn string `xml:"ipamPoolArn"` + IpamScopeArn string `xml:"ipamScopeArn"` + IpamScopeType string `xml:"ipamScopeType"` + AddressFamily string `xml:"addressFamily"` + Locale string `xml:"locale,omitempty"` + PoolDepth int `xml:"poolDepth"` + Description string `xml:"description,omitempty"` + State string `xml:"state"` + AllocationMinNetmaskLength int `xml:"allocationMinNetmaskLength,omitempty"` + AllocationMaxNetmaskLength int `xml:"allocationMaxNetmaskLength,omitempty"` + AllocationDefaultNetmaskLength int `xml:"allocationDefaultNetmaskLength,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type ipamPoolCidrXML struct { + IpamPoolCidrID string `xml:"ipamPoolCidrId"` + Cidr string `xml:"cidr,omitempty"` + NetmaskLength int `xml:"netmaskLength,omitempty"` + State string `xml:"state"` +} + +type ipamAllocationXML struct { + IpamPoolAllocationID string `xml:"ipamPoolAllocationId"` + Cidr string `xml:"cidr,omitempty"` + ResourceType string `xml:"resourceType,omitempty"` + ResourceID string `xml:"resourceId,omitempty"` + Description string `xml:"description,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +//nolint:gocyclo // flat action dispatch table +func (h *Handler) routeIPAM(w http.ResponseWriter, r *http.Request, action string) bool { + ip, ok := h.ipam() + if !ok { + return false + } + + switch action { + case "CreateIpam": + h.createIpam(w, r, ip) + case "DescribeIpams": + h.describeIpams(w, r, ip) + case "ModifyIpam": + h.modifyIpam(w, r, ip) + case "DeleteIpam": + h.deleteIpam(w, r, ip) + case "CreateIpamScope": + h.createIpamScope(w, r, ip) + case "DescribeIpamScopes": + h.describeIpamScopes(w, r, ip) + case "ModifyIpamScope": + h.modifyIpamScope(w, r, ip) + case "DeleteIpamScope": + h.deleteIpamScope(w, r, ip) + case "CreateIpamPool": + h.createIpamPool(w, r, ip) + case "DescribeIpamPools": + h.describeIpamPools(w, r, ip) + case "ModifyIpamPool": + h.modifyIpamPool(w, r, ip) + case "DeleteIpamPool": + h.deleteIpamPool(w, r, ip) + case "ProvisionIpamPoolCidr": + h.provisionIpamPoolCidr(w, r, ip) + case "DeprovisionIpamPoolCidr": + h.deprovisionIpamPoolCidr(w, r, ip) + case "GetIpamPoolCidrs": + h.getIpamPoolCidrs(w, r, ip) + case "AllocateIpamPoolCidr": + h.allocateIpamPoolCidr(w, r, ip) + case "ReleaseIpamPoolAllocation": + h.releaseIpamPoolAllocation(w, r, ip) + case "GetIpamPoolAllocations", "DescribeIpamPoolAllocations": + h.getIpamPoolAllocations(w, r, ip) + case "ModifyIpamPoolAllocation": + h.modifyIpamPoolAllocation(w, r, ip) + default: + return false + } + + return true +} + +func writeIPAMErr(w http.ResponseWriter, err error) { + writeErrWithNotFound(w, err, "InvalidIpamId.NotFound", "IncorrectState") +} + +// ---- IPAM ---- + +func (*Handler) createIpam(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + out, err := ip.CreateIpam(r.Context(), netdriver.IpamConfig{ + Description: r.Form.Get("Description"), + Tier: r.Form.Get("Tier"), + OperatingRegions: awsquery.ListStrings(r.Form, "OperatingRegion"), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "ipam"), + }) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpam(w, "CreateIpamResponse", out) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeIpams(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + items, err := ip.DescribeIpams(r.Context(), awsquery.ListStrings(r.Form, "IpamId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]ipamXML, 0, len(items)) + for i := range items { + out = append(out, toIpamXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeIpamsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ipamXML `xml:"ipamSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) modifyIpam(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + out, err := ip.ModifyIpam(r.Context(), r.Form.Get("IpamId"), r.Form.Get("Description")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpam(w, "ModifyIpamResponse", out) +} + +func (*Handler) deleteIpam(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + out, err := ip.DeleteIpam(r.Context(), r.Form.Get("IpamId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpam(w, "DeleteIpamResponse", out) +} + +func writeIpam(w http.ResponseWriter, root string, out *netdriver.Ipam) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Ipam ipamXML `xml:"ipam"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Ipam: toIpamXML(out)}) +} + +func toIpamXML(i *netdriver.Ipam) ipamXML { + x := ipamXML{ + IpamID: i.ID, IpamArn: i.ARN, IpamRegion: i.Region, + PublicDefaultScopeID: i.PublicDefaultScopeID, PrivateDefaultScopeID: i.PrivateDefaultScopeID, + ScopeCount: i.ScopeCount, + DefaultResourceDiscoveryID: i.DefaultResourceDiscoveryID, + DefaultResourceDiscoveryAssociationID: i.DefaultResourceDiscoveryAssociationID, + ResourceDiscoveryAssociationCount: i.ResourceDiscoveryAssociationCount, + Description: i.Description, Tier: i.Tier, State: i.State, + Tags: toTagItems(i.Tags), + } + + for _, reg := range i.OperatingRegions { + x.OperatingRegions = append(x.OperatingRegions, opRegXML{RegionName: reg}) + } + + return x +} + +// ---- IPAM Scope ---- + +func (*Handler) createIpamScope(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + out, err := ip.CreateIpamScope(r.Context(), netdriver.IpamScopeConfig{ + IpamID: r.Form.Get("IpamId"), + Description: r.Form.Get("Description"), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "ipam-scope"), + }) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamScope(w, "CreateIpamScopeResponse", out) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeIpamScopes(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + items, err := ip.DescribeIpamScopes(r.Context(), awsquery.ListStrings(r.Form, "IpamScopeId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]ipamScopeXML, 0, len(items)) + for i := range items { + out = append(out, toIpamScopeXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeIpamScopesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ipamScopeXML `xml:"ipamScopeSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) modifyIpamScope(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + out, err := ip.ModifyIpamScope(r.Context(), r.Form.Get("IpamScopeId"), r.Form.Get("Description")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamScope(w, "ModifyIpamScopeResponse", out) +} + +func (*Handler) deleteIpamScope(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + out, err := ip.DeleteIpamScope(r.Context(), r.Form.Get("IpamScopeId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamScope(w, "DeleteIpamScopeResponse", out) +} + +func writeIpamScope(w http.ResponseWriter, root string, out *netdriver.IpamScope) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Scope ipamScopeXML `xml:"ipamScope"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Scope: toIpamScopeXML(out)}) +} + +func toIpamScopeXML(s *netdriver.IpamScope) ipamScopeXML { + return ipamScopeXML{ + IpamScopeID: s.ID, IpamScopeArn: s.ARN, IpamArn: s.IpamARN, IpamScopeType: s.ScopeType, + IsDefault: s.IsDefault, PoolCount: s.PoolCount, Description: s.Description, State: s.State, + Tags: toTagItems(s.Tags), + } +} + +// ---- IPAM Pool ---- + +func (*Handler) createIpamPool(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + out, err := ip.CreateIpamPool(r.Context(), netdriver.IpamPoolConfig{ + IpamScopeID: r.Form.Get("IpamScopeId"), + AddressFamily: r.Form.Get("AddressFamily"), + Locale: r.Form.Get("Locale"), + Description: r.Form.Get("Description"), + AllocationMinNetmaskLength: atoiDefault(r.Form.Get("AllocationMinNetmaskLength")), + AllocationMaxNetmaskLength: atoiDefault(r.Form.Get("AllocationMaxNetmaskLength")), + AllocationDefaultNetmaskLength: atoiDefault(r.Form.Get("AllocationDefaultNetmaskLength")), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "ipam-pool"), + }) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamPool(w, "CreateIpamPoolResponse", out) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeIpamPools(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + items, err := ip.DescribeIpamPools(r.Context(), awsquery.ListStrings(r.Form, "IpamPoolId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]ipamPoolXML, 0, len(items)) + for i := range items { + out = append(out, toIpamPoolXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeIpamPoolsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ipamPoolXML `xml:"ipamPoolSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) modifyIpamPool(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + out, err := ip.ModifyIpamPool(r.Context(), r.Form.Get("IpamPoolId"), r.Form.Get("Description")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamPool(w, "ModifyIpamPoolResponse", out) +} + +func (*Handler) deleteIpamPool(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + out, err := ip.DeleteIpamPool(r.Context(), r.Form.Get("IpamPoolId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamPool(w, "DeleteIpamPoolResponse", out) +} + +func writeIpamPool(w http.ResponseWriter, root string, out *netdriver.IpamPool) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Pool ipamPoolXML `xml:"ipamPool"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Pool: toIpamPoolXML(out)}) +} + +func toIpamPoolXML(p *netdriver.IpamPool) ipamPoolXML { + return ipamPoolXML{ + IpamPoolID: p.ID, IpamPoolArn: p.ARN, IpamScopeArn: p.IpamScopeARN, IpamScopeType: p.IpamScopeType, + AddressFamily: p.AddressFamily, Locale: p.Locale, PoolDepth: p.PoolDepth, + Description: p.Description, State: p.State, + AllocationMinNetmaskLength: p.AllocationMinNetmaskLength, AllocationMaxNetmaskLength: p.AllocationMaxNetmaskLength, + AllocationDefaultNetmaskLength: p.AllocationDefaultNetmaskLength, Tags: toTagItems(p.Tags), + } +} + +// ---- Pool CIDRs ---- + +func (*Handler) provisionIpamPoolCidr(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + out, err := ip.ProvisionIpamPoolCidr(r.Context(), + r.Form.Get("IpamPoolId"), r.Form.Get("Cidr"), atoiDefault(r.Form.Get("NetmaskLength"))) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamPoolCidr(w, "ProvisionIpamPoolCidrResponse", out) +} + +func (*Handler) deprovisionIpamPoolCidr(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + out, err := ip.DeprovisionIpamPoolCidr(r.Context(), r.Form.Get("IpamPoolId"), r.Form.Get("Cidr")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamPoolCidr(w, "DeprovisionIpamPoolCidrResponse", out) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) getIpamPoolCidrs(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + items, err := ip.GetIpamPoolCidrs(r.Context(), r.Form.Get("IpamPoolId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]ipamPoolCidrXML, 0, len(items)) + for i := range items { + out = append(out, toIpamPoolCidrXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetIpamPoolCidrsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ipamPoolCidrXML `xml:"ipamPoolCidrSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func writeIpamPoolCidr(w http.ResponseWriter, root string, out *netdriver.IpamPoolCidr) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Cidr ipamPoolCidrXML `xml:"ipamPoolCidr"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Cidr: toIpamPoolCidrXML(out)}) +} + +func toIpamPoolCidrXML(c *netdriver.IpamPoolCidr) ipamPoolCidrXML { + return ipamPoolCidrXML{ + IpamPoolCidrID: c.ID, Cidr: c.CIDR, NetmaskLength: c.NetmaskLength, State: c.State, + } +} + +// ---- Pool Allocations ---- + +func (*Handler) allocateIpamPoolCidr(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + out, err := ip.AllocateIpamPoolCidr(r.Context(), netdriver.AllocateIpamPoolCidrConfig{ + IpamPoolID: r.Form.Get("IpamPoolId"), + CIDR: r.Form.Get("Cidr"), + NetmaskLength: atoiDefault(r.Form.Get("NetmaskLength")), + Description: r.Form.Get("Description"), + }) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamAllocation(w, "AllocateIpamPoolCidrResponse", out) +} + +func (*Handler) releaseIpamPoolAllocation(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + err := ip.ReleaseIpamPoolAllocation(r.Context(), r.Form.Get("IpamPoolId"), r.Form.Get("IpamPoolAllocationId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"ReleaseIpamPoolAllocationResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Success bool `xml:"success"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Success: true}) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) getIpamPoolAllocations(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + items, err := ip.GetIpamPoolAllocations(r.Context(), r.Form.Get("IpamPoolId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]ipamAllocationXML, 0, len(items)) + for i := range items { + out = append(out, toIpamAllocationXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetIpamPoolAllocationsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ipamAllocationXML `xml:"ipamPoolAllocationSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) modifyIpamPoolAllocation(w http.ResponseWriter, r *http.Request, ip netdriver.IPAM) { + out, err := ip.ModifyIpamPoolAllocation(r.Context(), r.Form.Get("IpamPoolAllocationId"), r.Form.Get("Description")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamAllocation(w, "ModifyIpamPoolAllocationResponse", out) +} + +func writeIpamAllocation(w http.ResponseWriter, root string, out *netdriver.IpamPoolAllocation) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Alloc ipamAllocationXML `xml:"ipamPoolAllocation"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Alloc: toIpamAllocationXML(out)}) +} + +func toIpamAllocationXML(a *netdriver.IpamPoolAllocation) ipamAllocationXML { + return ipamAllocationXML{ + IpamPoolAllocationID: a.ID, Cidr: a.CIDR, ResourceType: a.ResourceType, ResourceID: a.ResourceID, + Description: a.Description, Tags: toTagItems(a.Tags), + } +} + +func atoiDefault(s string) int { + n, _ := strconv.Atoi(s) + + return n +} diff --git a/server/aws/ec2/ipam_byoip.go b/server/aws/ec2/ipam_byoip.go new file mode 100644 index 00000000..b98209a4 --- /dev/null +++ b/server/aws/ec2/ipam_byoip.go @@ -0,0 +1,274 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) ipamByoasn() (netdriver.IPAMByoasn, bool) { + i, ok := h.vpc.(netdriver.IPAMByoasn) + + return i, ok +} + +func (h *Handler) ipamByoip() (netdriver.IPAMByoip, bool) { + i, ok := h.vpc.(netdriver.IPAMByoip) + + return i, ok +} + +type byoasnXML struct { + Asn string `xml:"asn"` + IpamID string `xml:"ipamId,omitempty"` + State string `xml:"state"` + StatusMessage string `xml:"statusMessage,omitempty"` +} + +type asnAssociationXML struct { + Asn string `xml:"asn"` + Cidr string `xml:"cidr"` + State string `xml:"state"` + StatusMessage string `xml:"statusMessage,omitempty"` +} + +type byoipCidrXML struct { + Cidr string `xml:"cidr"` + Description string `xml:"description,omitempty"` + State string `xml:"state"` + StatusMessage string `xml:"statusMessage,omitempty"` + NetworkBorderGroup string `xml:"networkBorderGroup,omitempty"` + AdvertisementType string `xml:"advertisementType,omitempty"` + AsnAssociations []asnAssociationXML `xml:"asnAssociationSet>item,omitempty"` +} + +func (h *Handler) routeIPAMByoip(w http.ResponseWriter, r *http.Request, action string) bool { + handledAsn := h.routeIPAMByoasn(w, r, action) + if handledAsn { + return true + } + + ip, ok := h.ipamByoip() + if !ok { + return false + } + + switch action { + case "ProvisionByoipCidr": + h.provisionByoipCidr(w, r, ip) + case "DeprovisionByoipCidr": + h.deprovisionByoipCidr(w, r, ip) + case "MoveByoipCidrToIpam": + h.moveByoipCidrToIpam(w, r, ip) + case "DescribeByoipCidrs": + h.describeByoipCidrs(w, r, ip) + case "AdvertiseByoipCidr": + h.advertiseByoipCidr(w, r, ip) + case "WithdrawByoipCidr": + h.withdrawByoipCidr(w, r, ip) + default: + return false + } + + return true +} + +func (h *Handler) routeIPAMByoasn(w http.ResponseWriter, r *http.Request, action string) bool { + ip, ok := h.ipamByoasn() + if !ok { + return false + } + + switch action { + case "ProvisionIpamByoasn": + h.provisionIpamByoasn(w, r, ip) + case "DeprovisionIpamByoasn": + h.deprovisionIpamByoasn(w, r, ip) + case "DescribeIpamByoasn": + h.describeIpamByoasn(w, r, ip) + case "AssociateIpamByoasn": + h.associateIpamByoasn(w, r, ip) + case "DisassociateIpamByoasn": + h.disassociateIpamByoasn(w, r, ip) + default: + return false + } + + return true +} + +func (*Handler) provisionIpamByoasn(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMByoasn) { + out, err := ip.ProvisionIpamByoasn(r.Context(), r.Form.Get("IpamId"), r.Form.Get("Asn")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeByoasn(w, "ProvisionIpamByoasnResponse", out) +} + +func (*Handler) deprovisionIpamByoasn(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMByoasn) { + out, err := ip.DeprovisionIpamByoasn(r.Context(), r.Form.Get("IpamId"), r.Form.Get("Asn")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeByoasn(w, "DeprovisionIpamByoasnResponse", out) +} + +func (*Handler) describeIpamByoasn(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMByoasn) { + items, err := ip.DescribeIpamByoasn(r.Context()) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]byoasnXML, 0, len(items)) + for i := range items { + out = append(out, byoasnXML{Asn: items[i].Asn, IpamID: items[i].IpamID, State: items[i].State, StatusMessage: items[i].StatusMessage}) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeIpamByoasnResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []byoasnXML `xml:"byoasnSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) associateIpamByoasn(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMByoasn) { + out, err := ip.AssociateIpamByoasn(r.Context(), r.Form.Get("Asn"), r.Form.Get("Cidr")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeAsnAssociation(w, "AssociateIpamByoasnResponse", out) +} + +func (*Handler) disassociateIpamByoasn(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMByoasn) { + out, err := ip.DisassociateIpamByoasn(r.Context(), r.Form.Get("Asn"), r.Form.Get("Cidr")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeAsnAssociation(w, "DisassociateIpamByoasnResponse", out) +} + +func writeByoasn(w http.ResponseWriter, root string, out *netdriver.Byoasn) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Byoasn byoasnXML `xml:"byoasn"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Byoasn: byoasnXML{ + Asn: out.Asn, IpamID: out.IpamID, State: out.State, StatusMessage: out.StatusMessage, + }}) +} + +func writeAsnAssociation(w http.ResponseWriter, root string, out *netdriver.AsnAssociation) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Assoc asnAssociationXML `xml:"asnAssociation"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Assoc: asnAssociationXML{ + Asn: out.Asn, Cidr: out.CIDR, State: out.State, StatusMessage: out.StatusMessage, + }}) +} + +func (*Handler) provisionByoipCidr(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMByoip) { + out, err := ip.ProvisionByoipCidr(r.Context(), r.Form.Get("Cidr"), r.Form.Get("Description")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeByoipCidr(w, "ProvisionByoipCidrResponse", out) +} + +func (*Handler) deprovisionByoipCidr(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMByoip) { + out, err := ip.DeprovisionByoipCidr(r.Context(), r.Form.Get("Cidr")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeByoipCidr(w, "DeprovisionByoipCidrResponse", out) +} + +func (*Handler) moveByoipCidrToIpam(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMByoip) { + out, err := ip.MoveByoipCidrToIpam(r.Context(), r.Form.Get("Cidr"), r.Form.Get("IpamPoolId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeByoipCidr(w, "MoveByoipCidrToIpamResponse", out) +} + +func (*Handler) describeByoipCidrs(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMByoip) { + items, err := ip.DescribeByoipCidrs(r.Context()) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]byoipCidrXML, 0, len(items)) + for i := range items { + out = append(out, toByoipCidrXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeByoipCidrsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []byoipCidrXML `xml:"byoipCidrSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) advertiseByoipCidr(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMByoip) { + out, err := ip.AdvertiseByoipCidr(r.Context(), r.Form.Get("Cidr")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeByoipCidr(w, "AdvertiseByoipCidrResponse", out) +} + +func (*Handler) withdrawByoipCidr(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMByoip) { + out, err := ip.WithdrawByoipCidr(r.Context(), r.Form.Get("Cidr")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeByoipCidr(w, "WithdrawByoipCidrResponse", out) +} + +func writeByoipCidr(w http.ResponseWriter, root string, out *netdriver.ByoipCidr) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Cidr byoipCidrXML `xml:"byoipCidr"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Cidr: toByoipCidrXML(out)}) +} + +func toByoipCidrXML(bc *netdriver.ByoipCidr) byoipCidrXML { + x := byoipCidrXML{ + Cidr: bc.CIDR, Description: bc.Description, State: bc.State, StatusMessage: bc.StatusMessage, + NetworkBorderGroup: bc.NetworkBorderGroup, AdvertisementType: bc.AdvertisementType, + } + + for _, a := range bc.AsnAssociations { + x.AsnAssociations = append(x.AsnAssociations, asnAssociationXML{Asn: a.Asn, Cidr: a.CIDR, State: a.State, StatusMessage: a.StatusMessage}) + } + + return x +} diff --git a/server/aws/ec2/ipam_discovery.go b/server/aws/ec2/ipam_discovery.go new file mode 100644 index 00000000..83f5f471 --- /dev/null +++ b/server/aws/ec2/ipam_discovery.go @@ -0,0 +1,317 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) ipamDiscovery() (netdriver.IPAMDiscovery, bool) { + i, ok := h.vpc.(netdriver.IPAMDiscovery) + + return i, ok +} + +type ipamRDXML struct { + IpamResourceDiscoveryID string `xml:"ipamResourceDiscoveryId"` + IpamResourceDiscoveryArn string `xml:"ipamResourceDiscoveryArn"` + IpamResourceDiscoveryRegion string `xml:"ipamResourceDiscoveryRegion,omitempty"` + OwnerID string `xml:"ownerId,omitempty"` + OperatingRegions []opRegXML `xml:"operatingRegionSet>item,omitempty"` + Description string `xml:"description,omitempty"` + IsDefault bool `xml:"isDefault"` + State string `xml:"state"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type ipamRDAssocXML struct { + IpamResourceDiscoveryAssociationID string `xml:"ipamResourceDiscoveryAssociationId"` + IpamResourceDiscoveryAssociationArn string `xml:"ipamResourceDiscoveryAssociationArn"` + IpamID string `xml:"ipamId"` + IpamArn string `xml:"ipamArn,omitempty"` + IpamRegion string `xml:"ipamRegion,omitempty"` + IpamResourceDiscoveryID string `xml:"ipamResourceDiscoveryId"` + OwnerID string `xml:"ownerId,omitempty"` + IsDefault bool `xml:"isDefault"` + ResourceDiscoveryStatus string `xml:"resourceDiscoveryStatus,omitempty"` + State string `xml:"state"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +//nolint:gocyclo // flat action dispatch table +func (h *Handler) routeIPAMDiscovery(w http.ResponseWriter, r *http.Request, action string) bool { + ip, ok := h.ipamDiscovery() + if !ok { + return false + } + + switch action { + case "CreateIpamResourceDiscovery": + h.createIpamRD(w, r, ip) + case "DescribeIpamResourceDiscoveries": + h.describeIpamRDs(w, r, ip) + case "ModifyIpamResourceDiscovery": + h.modifyIpamRD(w, r, ip) + case "DeleteIpamResourceDiscovery": + h.deleteIpamRD(w, r, ip) + case "AssociateIpamResourceDiscovery": + h.associateIpamRD(w, r, ip) + case "DisassociateIpamResourceDiscovery": + h.disassociateIpamRD(w, r, ip) + case "DescribeIpamResourceDiscoveryAssociations": + h.describeIpamRDAssocs(w, r, ip) + case "GetIpamDiscoveredAccounts": + h.getIpamDiscoveredAccounts(w, r, ip) + case "GetIpamDiscoveredResourceCidrs": + h.getIpamDiscoveredResourceCidrs(w, r, ip) + case "GetIpamDiscoveredPublicAddresses": + h.getIpamDiscoveredPublicAddresses(w, r, ip) + default: + return false + } + + return true +} + +func (*Handler) createIpamRD(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMDiscovery) { + out, err := ip.CreateIpamResourceDiscovery(r.Context(), netdriver.IpamResourceDiscoveryConfig{ + Description: r.Form.Get("Description"), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "ipam-resource-discovery"), + }) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamRD(w, "CreateIpamResourceDiscoveryResponse", out) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeIpamRDs(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMDiscovery) { + items, err := ip.DescribeIpamResourceDiscoveries(r.Context(), awsquery.ListStrings(r.Form, "IpamResourceDiscoveryId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]ipamRDXML, 0, len(items)) + for i := range items { + out = append(out, toIpamRDXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeIpamResourceDiscoveriesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ipamRDXML `xml:"ipamResourceDiscoverySet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) modifyIpamRD(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMDiscovery) { + out, err := ip.ModifyIpamResourceDiscovery(r.Context(), + r.Form.Get("IpamResourceDiscoveryId"), r.Form.Get("Description"), awsquery.ListStrings(r.Form, "AddOperatingRegion")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamRD(w, "ModifyIpamResourceDiscoveryResponse", out) +} + +func (*Handler) deleteIpamRD(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMDiscovery) { + out, err := ip.DeleteIpamResourceDiscovery(r.Context(), r.Form.Get("IpamResourceDiscoveryId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamRD(w, "DeleteIpamResourceDiscoveryResponse", out) +} + +func writeIpamRD(w http.ResponseWriter, root string, out *netdriver.IpamResourceDiscovery) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + RD ipamRDXML `xml:"ipamResourceDiscovery"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, RD: toIpamRDXML(out)}) +} + +func toIpamRDXML(rd *netdriver.IpamResourceDiscovery) ipamRDXML { + x := ipamRDXML{ + IpamResourceDiscoveryID: rd.ID, IpamResourceDiscoveryArn: rd.ARN, IpamResourceDiscoveryRegion: rd.Region, + OwnerID: rd.OwnerID, Description: rd.Description, IsDefault: rd.IsDefault, State: rd.State, + Tags: toTagItems(rd.Tags), + } + + for _, reg := range rd.OperatingRegions { + x.OperatingRegions = append(x.OperatingRegions, opRegXML{RegionName: reg}) + } + + return x +} + +func (*Handler) associateIpamRD(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMDiscovery) { + out, err := ip.AssociateIpamResourceDiscovery(r.Context(), + r.Form.Get("IpamId"), r.Form.Get("IpamResourceDiscoveryId"), + mergeTagSpecs(awsquery.TagSpecs(r.Form), "ipam-resource-discovery-association")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamRDAssoc(w, "AssociateIpamResourceDiscoveryResponse", out) +} + +func (*Handler) disassociateIpamRD(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMDiscovery) { + out, err := ip.DisassociateIpamResourceDiscovery(r.Context(), r.Form.Get("IpamResourceDiscoveryAssociationId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamRDAssoc(w, "DisassociateIpamResourceDiscoveryResponse", out) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeIpamRDAssocs(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMDiscovery) { + items, err := ip.DescribeIpamResourceDiscoveryAssociations(r.Context(), awsquery.ListStrings(r.Form, "IpamResourceDiscoveryAssociationId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]ipamRDAssocXML, 0, len(items)) + for i := range items { + out = append(out, toIpamRDAssocXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeIpamResourceDiscoveryAssociationsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ipamRDAssocXML `xml:"ipamResourceDiscoveryAssociationSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func writeIpamRDAssoc(w http.ResponseWriter, root string, out *netdriver.IpamResourceDiscoveryAssociation) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Assoc ipamRDAssocXML `xml:"ipamResourceDiscoveryAssociation"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Assoc: toIpamRDAssocXML(out)}) +} + +func toIpamRDAssocXML(a *netdriver.IpamResourceDiscoveryAssociation) ipamRDAssocXML { + return ipamRDAssocXML{ + IpamResourceDiscoveryAssociationID: a.ID, IpamResourceDiscoveryAssociationArn: a.ARN, + IpamID: a.IpamID, IpamArn: a.IpamARN, IpamRegion: a.IpamRegion, IpamResourceDiscoveryID: a.ResourceDiscoveryID, + OwnerID: a.OwnerID, IsDefault: a.IsDefault, ResourceDiscoveryStatus: a.ResourceDiscoveryStatus, State: a.State, + Tags: toTagItems(a.Tags), + } +} + +func (*Handler) getIpamDiscoveredAccounts(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMDiscovery) { + items, err := ip.GetIpamDiscoveredAccounts(r.Context(), r.Form.Get("IpamResourceDiscoveryId"), r.Form.Get("DiscoveryRegion")) + if err != nil { + writeIPAMErr(w, err) + return + } + + type accXML struct { + AccountID string `xml:"accountId"` + DiscoveryRegion string `xml:"discoveryRegion"` + } + + out := make([]accXML, 0, len(items)) + for i := range items { + out = append(out, accXML{AccountID: items[i].AccountID, DiscoveryRegion: items[i].DiscoveryRegion}) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetIpamDiscoveredAccountsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []accXML `xml:"ipamDiscoveredAccountSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) getIpamDiscoveredResourceCidrs(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMDiscovery) { + items, err := ip.GetIpamDiscoveredResourceCidrs(r.Context(), r.Form.Get("IpamResourceDiscoveryId"), r.Form.Get("ResourceRegion")) + if err != nil { + writeIPAMErr(w, err) + return + } + + type drcXML struct { + IpamResourceDiscoveryID string `xml:"ipamResourceDiscoveryId"` + ResourceCidr string `xml:"resourceCidr"` + ResourceID string `xml:"resourceId"` + ResourceType string `xml:"resourceType"` + ResourceRegion string `xml:"resourceRegion,omitempty"` + ResourceOwnerID string `xml:"resourceOwnerId,omitempty"` + VpcID string `xml:"vpcId,omitempty"` + IPSource string `xml:"ipSource,omitempty"` + IPUsage float64 `xml:"ipUsage,omitempty"` + SampleTime string `xml:"sampleTime,omitempty"` + } + + out := make([]drcXML, 0, len(items)) + for i := range items { + out = append(out, drcXML{ + IpamResourceDiscoveryID: items[i].ResourceDiscoveryID, ResourceCidr: items[i].ResourceCIDR, + ResourceID: items[i].ResourceID, ResourceType: items[i].ResourceType, ResourceRegion: items[i].ResourceRegion, + ResourceOwnerID: items[i].ResourceOwnerID, VpcID: items[i].VPCID, IPSource: items[i].IPSource, + IPUsage: items[i].IPUsage, SampleTime: items[i].SampleTime.Format("2006-01-02T15:04:05Z"), + }) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetIpamDiscoveredResourceCidrsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []drcXML `xml:"ipamDiscoveredResourceCidrSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) getIpamDiscoveredPublicAddresses(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMDiscovery) { + items, err := ip.GetIpamDiscoveredPublicAddresses(r.Context(), r.Form.Get("IpamResourceDiscoveryId"), r.Form.Get("AddressRegion")) + if err != nil { + writeIPAMErr(w, err) + return + } + + type paXML struct { + IpamResourceDiscoveryID string `xml:"ipamResourceDiscoveryId"` + Address string `xml:"address"` + AddressAllocationID string `xml:"addressAllocationId,omitempty"` + AddressOwnerID string `xml:"addressOwnerId,omitempty"` + AddressRegion string `xml:"addressRegion,omitempty"` + AddressType string `xml:"addressType,omitempty"` + AssociationStatus string `xml:"associationStatus,omitempty"` + Service string `xml:"service,omitempty"` + SampleTime string `xml:"sampleTime,omitempty"` + } + + out := make([]paXML, 0, len(items)) + for i := range items { + out = append(out, paXML{ + IpamResourceDiscoveryID: items[i].ResourceDiscoveryID, Address: items[i].Address, + AddressAllocationID: items[i].AddressAllocationID, AddressOwnerID: items[i].AddressOwnerID, + AddressRegion: items[i].AddressRegion, AddressType: items[i].AddressType, + AssociationStatus: items[i].AssociationStatus, Service: items[i].Service, + SampleTime: items[i].SampleTime.Format("2006-01-02T15:04:05Z"), + }) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetIpamDiscoveredPublicAddressesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []paXML `xml:"ipamDiscoveredPublicAddressSet>item"` + OldestSample string `xml:"oldestSampleTime,omitempty"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} diff --git a/server/aws/ec2/ipam_policy.go b/server/aws/ec2/ipam_policy.go new file mode 100644 index 00000000..74996f02 --- /dev/null +++ b/server/aws/ec2/ipam_policy.go @@ -0,0 +1,299 @@ +package ec2 + +import ( + "encoding/xml" + "fmt" + "net/http" + "net/url" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) ipamPolicy() (netdriver.IPAMPolicy, bool) { + i, ok := h.vpc.(netdriver.IPAMPolicy) + + return i, ok +} + +type ipamPolicyXML struct { + IpamPolicyID string `xml:"ipamPolicyId"` + IpamPolicyArn string `xml:"ipamPolicyArn"` + IpamID string `xml:"ipamId,omitempty"` + IpamRegion string `xml:"ipamPolicyRegion,omitempty"` + OwnerID string `xml:"ownerId,omitempty"` + State string `xml:"state"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +//nolint:gocyclo,dupl // flat action dispatch table +func (h *Handler) routeIPAMPolicy(w http.ResponseWriter, r *http.Request, action string) bool { + ip, ok := h.ipamPolicy() + if !ok { + return false + } + + switch action { + case "CreateIpamPolicy": + h.createIpamPolicy(w, r, ip) + case "DeleteIpamPolicy": + h.deleteIpamPolicy(w, r, ip) + case "DescribeIpamPolicies": + h.describeIpamPolicies(w, r, ip) + case "EnableIpamPolicy": + h.enableIpamPolicy(w, r, ip) + case "DisableIpamPolicy": + h.disableIpamPolicy(w, r, ip) + case "GetEnabledIpamPolicy": + h.getEnabledIpamPolicy(w, r, ip) + case "ModifyIpamPolicyAllocationRules": + h.modifyIpamPolicyAllocationRules(w, r, ip) + case "GetIpamPolicyAllocationRules": + h.getIpamPolicyAllocationRules(w, r, ip) + case "GetIpamPolicyOrganizationTargets": + h.getIpamPolicyOrganizationTargets(w, r, ip) + case "EnableIpamOrganizationAdminAccount": + h.enableIpamOrgAdmin(w, r, ip) + case "DisableIpamOrganizationAdminAccount": + h.disableIpamOrgAdmin(w, r, ip) + default: + return false + } + + return true +} + +func (*Handler) createIpamPolicy(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPolicy) { + out, err := ip.CreateIpamPolicy(r.Context(), r.Form.Get("IpamId"), mergeTagSpecs(awsquery.TagSpecs(r.Form), "ipam-policy")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamPolicy(w, "CreateIpamPolicyResponse", out) +} + +func (*Handler) deleteIpamPolicy(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPolicy) { + out, err := ip.DeleteIpamPolicy(r.Context(), r.Form.Get("IpamPolicyId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamPolicy(w, "DeleteIpamPolicyResponse", out) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeIpamPolicies(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPolicy) { + items, err := ip.DescribeIpamPolicies(r.Context(), awsquery.ListStrings(r.Form, "IpamPolicyId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]ipamPolicyXML, 0, len(items)) + for i := range items { + out = append(out, toIpamPolicyXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeIpamPoliciesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ipamPolicyXML `xml:"ipamPolicySet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) enableIpamPolicy(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPolicy) { + id, err := ip.EnableIpamPolicy(r.Context(), r.Form.Get("IpamPolicyId"), r.Form.Get("OrganizationTargetId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"EnableIpamPolicyResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + IpamPolicyID string `xml:"ipamPolicyId"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, IpamPolicyID: id}) +} + +func (*Handler) disableIpamPolicy(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPolicy) { + if err := ip.DisableIpamPolicy(r.Context(), r.Form.Get("IpamPolicyId")); err != nil { + writeIPAMErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DisableIpamPolicyResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Return bool `xml:"return"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Return: true}) +} + +func (*Handler) getEnabledIpamPolicy(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPolicy) { + id, enabled, managedBy, err := ip.GetEnabledIpamPolicy(r.Context()) + if err != nil { + writeIPAMErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetEnabledIpamPolicyResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + IpamPolicyID string `xml:"ipamPolicyId,omitempty"` + IpamPolicyEnabled bool `xml:"ipamPolicyEnabled"` + ManagedBy string `xml:"managedBy,omitempty"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, IpamPolicyID: id, IpamPolicyEnabled: enabled, ManagedBy: managedBy}) +} + +// allocationRuleXML is one ; matches the SDK's +// IpamPolicyAllocationRule (sourceIpamPoolId only). +type allocationRuleXML struct { + SourceIpamPoolID string `xml:"sourceIpamPoolId,omitempty"` +} + +// ipamPolicyDocumentXML matches types.IpamPolicyDocument: an allocationRuleSet +// plus the ipamPolicyId/locale/resourceType the rules are scoped to. +type ipamPolicyDocumentXML struct { + IpamPolicyID string `xml:"ipamPolicyId,omitempty"` + Locale string `xml:"locale,omitempty"` + ResourceType string `xml:"resourceType,omitempty"` + AllocationRule []allocationRuleXML `xml:"allocationRuleSet>item,omitempty"` +} + +// parseAllocationRules reads the request's flattened AllocationRule.N list. +func parseAllocationRules(form url.Values) []netdriver.IpamAllocationRule { + idx := awsquery.CollectIndices(form, "AllocationRule") + rules := make([]netdriver.IpamAllocationRule, 0, len(idx)) + + for _, i := range idx { + pool := form.Get(fmt.Sprintf("AllocationRule.%d.SourceIpamPoolId", i)) + rules = append(rules, netdriver.IpamAllocationRule{SourceIpamPoolID: pool}) + } + + return rules +} + +func toIpamPolicyDocumentXML(p *netdriver.IpamPolicy) ipamPolicyDocumentXML { + doc := ipamPolicyDocumentXML{ + IpamPolicyID: p.ID, + Locale: p.Locale, + ResourceType: p.ResourceType, + } + + for _, rule := range p.AllocationRules { + doc.AllocationRule = append(doc.AllocationRule, allocationRuleXML{SourceIpamPoolID: rule.SourceIpamPoolID}) + } + + return doc +} + +func (*Handler) modifyIpamPolicyAllocationRules(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPolicy) { + out, err := ip.ModifyIpamPolicyAllocationRules(r.Context(), + r.Form.Get("IpamPolicyId"), r.Form.Get("Locale"), r.Form.Get("ResourceType"), + parseAllocationRules(r.Form)) + if err != nil { + writeIPAMErr(w, err) + return + } + + // The SDK output carries IpamPolicyDocument (element ipamPolicyDocument), + // not a Return field — emit the modified document so the caller sees it. + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"ModifyIpamPolicyAllocationRulesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Document ipamPolicyDocumentXML `xml:"ipamPolicyDocument"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Document: toIpamPolicyDocumentXML(out)}) +} + +func (*Handler) getIpamPolicyAllocationRules(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPolicy) { + p, err := ip.GetIpamPolicyAllocationRules(r.Context(), r.Form.Get("IpamPolicyId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + // Output is IpamPolicyDocuments []IpamPolicyDocument + NextToken. The + // emulator models one document (the policy's rules) per policy. + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetIpamPolicyAllocationRulesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ipamPolicyDocumentXML `xml:"ipamPolicyDocumentSet>item"` + NextToken string `xml:"nextToken,omitempty"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: []ipamPolicyDocumentXML{toIpamPolicyDocumentXML(p)}}) +} + +func (*Handler) getIpamPolicyOrganizationTargets(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPolicy) { + targets, err := ip.GetIpamPolicyOrganizationTargets(r.Context(), r.Form.Get("IpamPolicyId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + type targetXML struct { + OrganizationTargetID string `xml:"organizationTargetId"` + } + + out := make([]targetXML, 0, len(targets)) + for _, t := range targets { + out = append(out, targetXML{OrganizationTargetID: t}) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetIpamPolicyOrganizationTargetsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []targetXML `xml:"organizationTargetSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) enableIpamOrgAdmin(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPolicy) { + ok, err := ip.EnableIpamOrganizationAdminAccount(r.Context(), r.Form.Get("DelegatedAdminAccountId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamOrgAdminResult(w, "EnableIpamOrganizationAdminAccountResponse", ok) +} + +func (*Handler) disableIpamOrgAdmin(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPolicy) { + ok, err := ip.DisableIpamOrganizationAdminAccount(r.Context(), r.Form.Get("DelegatedAdminAccountId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamOrgAdminResult(w, "DisableIpamOrganizationAdminAccountResponse", ok) +} + +func writeIpamOrgAdminResult(w http.ResponseWriter, root string, success bool) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Success bool `xml:"success"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Success: success}) +} + +func writeIpamPolicy(w http.ResponseWriter, root string, out *netdriver.IpamPolicy) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Policy ipamPolicyXML `xml:"ipamPolicy"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Policy: toIpamPolicyXML(out)}) +} + +func toIpamPolicyXML(p *netdriver.IpamPolicy) ipamPolicyXML { + return ipamPolicyXML{ + IpamPolicyID: p.ID, IpamPolicyArn: p.ARN, IpamID: p.IpamID, IpamRegion: p.IpamRegion, + OwnerID: p.OwnerID, State: p.State, Tags: toTagItems(p.Tags), + } +} diff --git a/server/aws/ec2/ipam_resolver.go b/server/aws/ec2/ipam_resolver.go new file mode 100644 index 00000000..841820e1 --- /dev/null +++ b/server/aws/ec2/ipam_resolver.go @@ -0,0 +1,404 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) ipamResolver() (netdriver.IPAMPrefixListResolver, bool) { + i, ok := h.vpc.(netdriver.IPAMPrefixListResolver) + + return i, ok +} + +func (h *Handler) ipamToken() (netdriver.IPAMExternalToken, bool) { + i, ok := h.vpc.(netdriver.IPAMExternalToken) + + return i, ok +} + +type ipamResolverXML struct { + IpamPrefixListResolverID string `xml:"ipamPrefixListResolverId"` + IpamPrefixListResolverArn string `xml:"ipamPrefixListResolverArn"` + IpamID string `xml:"ipamId,omitempty"` + IpamArn string `xml:"ipamArn,omitempty"` + IpamRegion string `xml:"ipamRegion,omitempty"` + OwnerID string `xml:"ownerId,omitempty"` + AddressFamily string `xml:"addressFamily,omitempty"` + Description string `xml:"description,omitempty"` + State string `xml:"state"` + LastVersionCreationStatus string `xml:"lastVersionCreationStatus,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type ipamResolverTargetXML struct { + IpamPrefixListResolverTargetID string `xml:"ipamPrefixListResolverTargetId"` + IpamPrefixListResolverTargetArn string `xml:"ipamPrefixListResolverTargetArn"` + IpamPrefixListResolverID string `xml:"ipamPrefixListResolverId"` + OwnerID string `xml:"ownerId,omitempty"` + PrefixListID string `xml:"prefixListId"` + PrefixListRegion string `xml:"prefixListRegion,omitempty"` + DesiredVersion int `xml:"desiredVersion,omitempty"` + LastSyncedVersion int `xml:"lastSyncedVersion,omitempty"` + TrackLatestVersion bool `xml:"trackLatestVersion"` + State string `xml:"state"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type ipamTokenXML struct { + IpamExternalResourceVerificationTokenID string `xml:"ipamExternalResourceVerificationTokenId"` + IpamExternalResourceVerificationTokenArn string `xml:"ipamExternalResourceVerificationTokenArn"` + IpamID string `xml:"ipamId,omitempty"` + IpamArn string `xml:"ipamArn,omitempty"` + IpamRegion string `xml:"ipamRegion,omitempty"` + TokenName string `xml:"tokenName,omitempty"` + TokenValue string `xml:"tokenValue,omitempty"` + State string `xml:"state"` + Status string `xml:"status,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +//nolint:gocyclo // flat action dispatch table +func (h *Handler) routeIPAMResolver(w http.ResponseWriter, r *http.Request, action string) bool { + if h.routeIPAMToken(w, r, action) { + return true + } + + ip, ok := h.ipamResolver() + if !ok { + return false + } + + switch action { + case "CreateIpamPrefixListResolver": + h.createIpamResolver(w, r, ip) + case "DescribeIpamPrefixListResolvers": + h.describeIpamResolvers(w, r, ip) + case "ModifyIpamPrefixListResolver": + h.modifyIpamResolver(w, r, ip) + case "DeleteIpamPrefixListResolver": + h.deleteIpamResolver(w, r, ip) + case "CreateIpamPrefixListResolverTarget": + h.createIpamResolverTarget(w, r, ip) + case "DescribeIpamPrefixListResolverTargets": + h.describeIpamResolverTargets(w, r, ip) + case "ModifyIpamPrefixListResolverTarget": + h.modifyIpamResolverTarget(w, r, ip) + case "DeleteIpamPrefixListResolverTarget": + h.deleteIpamResolverTarget(w, r, ip) + case "GetIpamPrefixListResolverRules": + h.getIpamResolverRules(w, r, ip) + case "GetIpamPrefixListResolverVersions": + h.getIpamResolverVersions(w, r, ip) + case "GetIpamPrefixListResolverVersionEntries": + h.getIpamResolverVersionEntries(w, r, ip) + default: + return false + } + + return true +} + +func (h *Handler) routeIPAMToken(w http.ResponseWriter, r *http.Request, action string) bool { + ip, ok := h.ipamToken() + if !ok { + return false + } + + switch action { + case "CreateIpamExternalResourceVerificationToken": + h.createIpamToken(w, r, ip) + case "DeleteIpamExternalResourceVerificationToken": + h.deleteIpamToken(w, r, ip) + case "DescribeIpamExternalResourceVerificationTokens": + h.describeIpamTokens(w, r, ip) + default: + return false + } + + return true +} + +func (*Handler) createIpamResolver(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPrefixListResolver) { + out, err := ip.CreateIpamPrefixListResolver(r.Context(), + r.Form.Get("IpamId"), r.Form.Get("AddressFamily"), r.Form.Get("Description"), + mergeTagSpecs(awsquery.TagSpecs(r.Form), "ipam-prefix-list-resolver")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamResolver(w, "CreateIpamPrefixListResolverResponse", out) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeIpamResolvers(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPrefixListResolver) { + items, err := ip.DescribeIpamPrefixListResolvers(r.Context(), awsquery.ListStrings(r.Form, "IpamPrefixListResolverId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]ipamResolverXML, 0, len(items)) + for i := range items { + out = append(out, toIpamResolverXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeIpamPrefixListResolversResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ipamResolverXML `xml:"ipamPrefixListResolverSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) modifyIpamResolver(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPrefixListResolver) { + out, err := ip.ModifyIpamPrefixListResolver(r.Context(), r.Form.Get("IpamPrefixListResolverId"), r.Form.Get("Description")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamResolver(w, "ModifyIpamPrefixListResolverResponse", out) +} + +func (*Handler) deleteIpamResolver(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPrefixListResolver) { + out, err := ip.DeleteIpamPrefixListResolver(r.Context(), r.Form.Get("IpamPrefixListResolverId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamResolver(w, "DeleteIpamPrefixListResolverResponse", out) +} + +func writeIpamResolver(w http.ResponseWriter, root string, out *netdriver.IpamPrefixListResolver) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Resolver ipamResolverXML `xml:"ipamPrefixListResolver"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Resolver: toIpamResolverXML(out)}) +} + +func toIpamResolverXML(r *netdriver.IpamPrefixListResolver) ipamResolverXML { + return ipamResolverXML{ + IpamPrefixListResolverID: r.ID, IpamPrefixListResolverArn: r.ARN, IpamID: r.IpamID, IpamArn: r.IpamARN, + IpamRegion: r.IpamRegion, OwnerID: r.OwnerID, AddressFamily: r.AddressFamily, Description: r.Description, + State: r.State, LastVersionCreationStatus: r.LastVersionCreationStatus, Tags: toTagItems(r.Tags), + } +} + +func (*Handler) createIpamResolverTarget(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPrefixListResolver) { + out, err := ip.CreateIpamPrefixListResolverTarget(r.Context(), + r.Form.Get("IpamPrefixListResolverId"), r.Form.Get("PrefixListId"), r.Form.Get("PrefixListRegion"), + atoiDefault(r.Form.Get("DesiredVersion")), r.Form.Get("TrackLatestVersion") == formTrue, + mergeTagSpecs(awsquery.TagSpecs(r.Form), "ipam-prefix-list-resolver-target")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamResolverTarget(w, "CreateIpamPrefixListResolverTargetResponse", out) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeIpamResolverTargets(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPrefixListResolver) { + items, err := ip.DescribeIpamPrefixListResolverTargets(r.Context(), awsquery.ListStrings(r.Form, "IpamPrefixListResolverTargetId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]ipamResolverTargetXML, 0, len(items)) + for i := range items { + out = append(out, toIpamResolverTargetXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeIpamPrefixListResolverTargetsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ipamResolverTargetXML `xml:"ipamPrefixListResolverTargetSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) modifyIpamResolverTarget(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPrefixListResolver) { + out, err := ip.ModifyIpamPrefixListResolverTarget(r.Context(), + r.Form.Get("IpamPrefixListResolverTargetId"), atoiDefault(r.Form.Get("DesiredVersion")), + r.Form.Get("TrackLatestVersion") == formTrue) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamResolverTarget(w, "ModifyIpamPrefixListResolverTargetResponse", out) +} + +func (*Handler) deleteIpamResolverTarget(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPrefixListResolver) { + out, err := ip.DeleteIpamPrefixListResolverTarget(r.Context(), r.Form.Get("IpamPrefixListResolverTargetId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamResolverTarget(w, "DeleteIpamPrefixListResolverTargetResponse", out) +} + +func writeIpamResolverTarget(w http.ResponseWriter, root string, out *netdriver.IpamPrefixListResolverTarget) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Target ipamResolverTargetXML `xml:"ipamPrefixListResolverTarget"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Target: toIpamResolverTargetXML(out)}) +} + +func toIpamResolverTargetXML(t *netdriver.IpamPrefixListResolverTarget) ipamResolverTargetXML { + return ipamResolverTargetXML{ + IpamPrefixListResolverTargetID: t.ID, IpamPrefixListResolverTargetArn: t.ARN, IpamPrefixListResolverID: t.ResolverID, + OwnerID: t.OwnerID, PrefixListID: t.PrefixListID, PrefixListRegion: t.PrefixListRegion, + DesiredVersion: t.DesiredVersion, LastSyncedVersion: t.LastSyncedVersion, TrackLatestVersion: t.TrackLatestVersion, + State: t.State, Tags: toTagItems(t.Tags), + } +} + +func (*Handler) getIpamResolverRules(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPrefixListResolver) { + items, err := ip.GetIpamPrefixListResolverRules(r.Context(), r.Form.Get("IpamPrefixListResolverId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + type ruleXML struct { + IpamPoolID string `xml:"ipamPoolId,omitempty"` + Cidr string `xml:"cidr,omitempty"` + } + + out := make([]ruleXML, 0, len(items)) + for i := range items { + out = append(out, ruleXML{IpamPoolID: items[i].IpamPoolID, Cidr: items[i].Cidr}) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetIpamPrefixListResolverRulesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ruleXML `xml:"ruleSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) getIpamResolverVersions(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPrefixListResolver) { + items, err := ip.GetIpamPrefixListResolverVersions(r.Context(), r.Form.Get("IpamPrefixListResolverId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + type versionXML struct { + Version int `xml:"version"` + } + + out := make([]versionXML, 0, len(items)) + for i := range items { + out = append(out, versionXML{Version: items[i].Version}) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetIpamPrefixListResolverVersionsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []versionXML `xml:"ipamPrefixListResolverVersionSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) getIpamResolverVersionEntries(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMPrefixListResolver) { + entries, err := ip.GetIpamPrefixListResolverVersionEntries(r.Context(), + r.Form.Get("IpamPrefixListResolverId"), atoiDefault(r.Form.Get("Version"))) + if err != nil { + writeIPAMErr(w, err) + return + } + + type entryXML struct { + Cidr string `xml:"cidr"` + Description string `xml:"description,omitempty"` + } + + out := make([]entryXML, 0, len(entries)) + for i := range entries { + out = append(out, entryXML{Cidr: entries[i].CIDR, Description: entries[i].Description}) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetIpamPrefixListResolverVersionEntriesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []entryXML `xml:"entrySet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) createIpamToken(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMExternalToken) { + out, err := ip.CreateIpamExternalResourceVerificationToken(r.Context(), + r.Form.Get("IpamId"), r.Form.Get("TokenName"), + mergeTagSpecs(awsquery.TagSpecs(r.Form), "ipam-external-resource-verification-token")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamToken(w, "CreateIpamExternalResourceVerificationTokenResponse", out) +} + +func (*Handler) deleteIpamToken(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMExternalToken) { + out, err := ip.DeleteIpamExternalResourceVerificationToken(r.Context(), r.Form.Get("IpamExternalResourceVerificationTokenId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + writeIpamToken(w, "DeleteIpamExternalResourceVerificationTokenResponse", out) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeIpamTokens(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMExternalToken) { + items, err := ip.DescribeIpamExternalResourceVerificationTokens( + r.Context(), awsquery.ListStrings(r.Form, "IpamExternalResourceVerificationTokenId"), + ) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]ipamTokenXML, 0, len(items)) + for i := range items { + out = append(out, toIpamTokenXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeIpamExternalResourceVerificationTokensResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ipamTokenXML `xml:"ipamExternalResourceVerificationTokenSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func writeIpamToken(w http.ResponseWriter, root string, out *netdriver.IpamExternalResourceVerificationToken) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:""` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Token ipamTokenXML `xml:"ipamExternalResourceVerificationToken"` + }{XMLName: xml.Name{Local: root}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Token: toIpamTokenXML(out)}) +} + +func toIpamTokenXML(t *netdriver.IpamExternalResourceVerificationToken) ipamTokenXML { + return ipamTokenXML{ + IpamExternalResourceVerificationTokenID: t.ID, IpamExternalResourceVerificationTokenArn: t.ARN, + IpamID: t.IpamID, IpamArn: t.IpamARN, IpamRegion: t.IpamRegion, TokenName: t.TokenName, + TokenValue: t.TokenValue, State: t.State, Status: t.Status, Tags: toTagItems(t.Tags), + } +} diff --git a/server/aws/ec2/ipam_resources.go b/server/aws/ec2/ipam_resources.go new file mode 100644 index 00000000..82834c7b --- /dev/null +++ b/server/aws/ec2/ipam_resources.go @@ -0,0 +1,143 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) ipamResources() (netdriver.IPAMResources, bool) { + i, ok := h.vpc.(netdriver.IPAMResources) + + return i, ok +} + +type ipamResourceCidrXML struct { + IpamID string `xml:"ipamId,omitempty"` + IpamScopeID string `xml:"ipamScopeId,omitempty"` + IpamPoolID string `xml:"ipamPoolId,omitempty"` + ResourceCidr string `xml:"resourceCidr"` + ResourceID string `xml:"resourceId"` + ResourceName string `xml:"resourceName,omitempty"` + ResourceType string `xml:"resourceType"` + ResourceRegion string `xml:"resourceRegion,omitempty"` + ResourceOwnerID string `xml:"resourceOwnerId,omitempty"` + VpcID string `xml:"vpcId,omitempty"` + AvailabilityZone string `xml:"availabilityZoneId,omitempty"` + ComplianceStatus string `xml:"complianceStatus,omitempty"` + ManagementState string `xml:"managementState,omitempty"` + OverlapStatus string `xml:"overlapStatus,omitempty"` + IPUsage float64 `xml:"ipUsage,omitempty"` + Tags []tagItem `xml:"resourceTagSet>item,omitempty"` +} + +type ipamHistoryRecordXML struct { + ResourceCidr string `xml:"resourceCidr"` + ResourceID string `xml:"resourceId"` + ResourceType string `xml:"resourceType"` + ResourceRegion string `xml:"resourceRegion,omitempty"` + ResourceOwnerID string `xml:"resourceOwnerId,omitempty"` + VpcID string `xml:"vpcId,omitempty"` + ResourceComplianceStatus string `xml:"resourceComplianceStatus,omitempty"` + ResourceOverlapStatus string `xml:"resourceOverlapStatus,omitempty"` + SampledStartTime string `xml:"sampledStartTime,omitempty"` + SampledEndTime string `xml:"sampledEndTime,omitempty"` +} + +func (h *Handler) routeIPAMResources(w http.ResponseWriter, r *http.Request, action string) bool { + ip, ok := h.ipamResources() + if !ok { + return false + } + + switch action { + case "GetIpamResourceCidrs": + h.getIpamResourceCidrs(w, r, ip) + case "ModifyIpamResourceCidr": + h.modifyIpamResourceCidr(w, r, ip) + case "GetIpamAddressHistory": + h.getIpamAddressHistory(w, r, ip) + default: + return false + } + + return true +} + +func (*Handler) getIpamResourceCidrs(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMResources) { + items, err := ip.GetIpamResourceCidrs(r.Context(), r.Form.Get("IpamScopeId"), r.Form.Get("ResourceId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]ipamResourceCidrXML, 0, len(items)) + for i := range items { + out = append(out, toIpamResourceCidrXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetIpamResourceCidrsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ipamResourceCidrXML `xml:"ipamResourceCidrSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) modifyIpamResourceCidr(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMResources) { + out, err := ip.ModifyIpamResourceCidr(r.Context(), + r.Form.Get("ResourceId"), r.Form.Get("CurrentIpamScopeId"), r.Form.Get("DestinationIpamScopeId"), + r.Form.Get("Monitored") == formTrue) + if err != nil { + writeIPAMErr(w, err) + return + } + + x := toIpamResourceCidrXML(out) + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"ModifyIpamResourceCidrResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Cidr ipamResourceCidrXML `xml:"ipamResourceCidr"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Cidr: x}) +} + +func (*Handler) getIpamAddressHistory(w http.ResponseWriter, r *http.Request, ip netdriver.IPAMResources) { + items, err := ip.GetIpamAddressHistory(r.Context(), r.Form.Get("Cidr"), r.Form.Get("IpamScopeId")) + if err != nil { + writeIPAMErr(w, err) + return + } + + out := make([]ipamHistoryRecordXML, 0, len(items)) + for i := range items { + out = append(out, ipamHistoryRecordXML{ + ResourceCidr: items[i].ResourceCIDR, ResourceID: items[i].ResourceID, ResourceType: items[i].ResourceType, + ResourceRegion: items[i].ResourceRegion, ResourceOwnerID: items[i].ResourceOwnerID, VpcID: items[i].VPCID, + ResourceComplianceStatus: items[i].ResourceComplianceStatus, ResourceOverlapStatus: items[i].ResourceOverlapStatus, + SampledStartTime: items[i].SampledStartTime.Format("2006-01-02T15:04:05Z"), + SampledEndTime: items[i].SampledEndTime.Format("2006-01-02T15:04:05Z"), + }) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetIpamAddressHistoryResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []ipamHistoryRecordXML `xml:"historyRecordSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func toIpamResourceCidrXML(c *netdriver.IpamResourceCidr) ipamResourceCidrXML { + return ipamResourceCidrXML{ + IpamID: c.IpamID, IpamScopeID: c.IpamScopeID, IpamPoolID: c.IpamPoolID, + ResourceCidr: c.ResourceCIDR, ResourceID: c.ResourceID, ResourceName: c.ResourceName, + ResourceType: c.ResourceType, ResourceRegion: c.ResourceRegion, ResourceOwnerID: c.ResourceOwnerID, + VpcID: c.VPCID, AvailabilityZone: c.AvailabilityZone, ComplianceStatus: c.ComplianceStatus, + ManagementState: c.ManagementState, OverlapStatus: c.OverlapStatus, IPUsage: c.IPUsage, + Tags: toTagItems(c.Tags), + } +} diff --git a/server/aws/ec2/metadata.go b/server/aws/ec2/metadata.go new file mode 100644 index 00000000..3c53e948 --- /dev/null +++ b/server/aws/ec2/metadata.go @@ -0,0 +1,142 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" +) + +// commonRegions is the representative region set DescribeRegions reports. Tools +// call DescribeRegions to validate a region exists before provisioning; a fixed +// common set satisfies that without pretending to enumerate every AWS region. +var commonRegions = []string{ //nolint:gochecknoglobals // static lookup table + "us-east-1", "us-east-2", "us-west-1", "us-west-2", + "eu-west-1", "eu-west-2", "eu-central-1", + "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ap-northeast-1", +} + +type regionXML struct { + RegionName string `xml:"regionName"` + Endpoint string `xml:"regionEndpoint"` + OptInStatus string `xml:"optInStatus"` +} + +type describeRegionsResponseXML struct { + XMLName xml.Name `xml:"DescribeRegionsResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + Regions []regionXML `xml:"regionInfo>item"` +} + +func (h *Handler) routeMetadata(w http.ResponseWriter, r *http.Request, action string) bool { + switch action { + case "DescribeRegions": + h.describeRegions(w, r) + case "DescribeInstanceTypes": + h.describeInstanceTypes(w, r) + default: + return false + } + + return true +} + +// describeRegions answers ec2:DescribeRegions. If explicit RegionName.N filters +// are supplied, only those are returned; otherwise the common set is reported. +func (*Handler) describeRegions(w http.ResponseWriter, r *http.Request) { + requested := awsquery.ListStrings(r.Form, "RegionName") + + names := commonRegions + if len(requested) > 0 { + names = requested + } + + out := make([]regionXML, 0, len(names)) + for _, name := range names { + out = append(out, regionXML{ + RegionName: name, + Endpoint: "ec2." + name + ".amazonaws.com", + OptInStatus: "opt-in-not-required", + }) + } + + awsquery.WriteXMLResponse(w, describeRegionsResponseXML{ + Xmlns: awsquery.Namespace, RequestID: awsquery.RequestID, Regions: out, + }) +} + +// instanceTypeSpec is the vCPU/memory profile reported for an instance type. +type instanceTypeSpec struct { + vcpus int + memoryMiB int +} + +// knownInstanceTypes maps the common instance types to their specs. An +// unrecognized type still gets a response (small default) so validation calls +// don't fail on a type the emulator hasn't enumerated. +var knownInstanceTypes = map[string]instanceTypeSpec{ //nolint:gochecknoglobals // static lookup table + "t2.micro": {1, 1024}, + "t2.small": {1, 2048}, + "t3.micro": {2, 1024}, + "t3.small": {2, 2048}, + "t3.medium": {2, 4096}, + "m5.large": {2, 8192}, + "m5.xlarge": {4, 16384}, + "c5.large": {2, 4096}, + "r5.large": {2, 16384}, +} + +type vCPUInfoXML struct { + DefaultVCpus int `xml:"defaultVCpus"` +} + +type memoryInfoXML struct { + SizeInMiB int `xml:"sizeInMiB"` +} + +type instanceTypeInfoXML struct { + InstanceType string `xml:"instanceType"` + VCPUInfo vCPUInfoXML `xml:"vCpuInfo"` + MemoryInfo memoryInfoXML `xml:"memoryInfo"` +} + +type describeInstanceTypesResponseXML struct { + XMLName xml.Name `xml:"DescribeInstanceTypesResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + InstanceTypes []instanceTypeInfoXML `xml:"instanceTypeSet>item"` +} + +// describeInstanceTypes answers ec2:DescribeInstanceTypes. Explicit +// InstanceType.N values are echoed with their (or a default) spec; with none +// supplied, the known set is reported. +func (*Handler) describeInstanceTypes(w http.ResponseWriter, r *http.Request) { + requested := awsquery.ListStrings(r.Form, "InstanceType") + + names := requested + if len(names) == 0 { + for name := range knownInstanceTypes { + names = append(names, name) + } + } + + out := make([]instanceTypeInfoXML, 0, len(names)) + + for _, name := range names { + spec, ok := knownInstanceTypes[name] + if !ok { + spec = instanceTypeSpec{vcpus: 2, memoryMiB: 4096} + } + + out = append(out, instanceTypeInfoXML{ + InstanceType: name, + VCPUInfo: vCPUInfoXML{DefaultVCpus: spec.vcpus}, + MemoryInfo: memoryInfoXML{SizeInMiB: spec.memoryMiB}, + }) + } + + awsquery.WriteXMLResponse(w, describeInstanceTypesResponseXML{ + Xmlns: awsquery.Namespace, RequestID: awsquery.RequestID, InstanceTypes: out, + }) +} diff --git a/server/aws/ec2/network_insights.go b/server/aws/ec2/network_insights.go new file mode 100644 index 00000000..02e9f028 --- /dev/null +++ b/server/aws/ec2/network_insights.go @@ -0,0 +1,598 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + "strconv" + "time" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) networkInsights() (netdriver.NetworkInsights, bool) { + n, ok := h.vpc.(netdriver.NetworkInsights) + + return n, ok +} + +func (h *Handler) routeNetworkInsights(w http.ResponseWriter, r *http.Request, action string) bool { + n, ok := h.networkInsights() + if !ok { + return false + } + + if h.routeNetworkInsightsPaths(w, r, action, n) { + return true + } + + return h.routeNetworkInsightsAccessScopes(w, r, action, n) +} + +func (h *Handler) routeNetworkInsightsPaths( + w http.ResponseWriter, r *http.Request, action string, n netdriver.NetworkInsights, +) bool { + switch action { + case "CreateNetworkInsightsPath": + h.createNetworkInsightsPath(w, r, n) + case "DeleteNetworkInsightsPath": + h.deleteNetworkInsightsPath(w, r, n) + case "DescribeNetworkInsightsPaths": + h.describeNetworkInsightsPaths(w, r, n) + case "StartNetworkInsightsAnalysis": + h.startNetworkInsightsAnalysis(w, r, n) + case "DeleteNetworkInsightsAnalysis": + h.deleteNetworkInsightsAnalysis(w, r, n) + case "DescribeNetworkInsightsAnalyses": + h.describeNetworkInsightsAnalyses(w, r, n) + default: + return false + } + + return true +} + +//nolint:dupl // parallel per-resource wire dispatch/marshaling +func (h *Handler) routeNetworkInsightsAccessScopes( + w http.ResponseWriter, r *http.Request, action string, n netdriver.NetworkInsights, +) bool { + switch action { + case "CreateNetworkInsightsAccessScope": + h.createNetworkInsightsAccessScope(w, r, n) + case "DeleteNetworkInsightsAccessScope": + h.deleteNetworkInsightsAccessScope(w, r, n) + case "DescribeNetworkInsightsAccessScopes": + h.describeNetworkInsightsAccessScopes(w, r, n) + case "GetNetworkInsightsAccessScopeContent": + h.getNetworkInsightsAccessScopeContent(w, r, n) + case "StartNetworkInsightsAccessScopeAnalysis": + h.startNetworkInsightsAccessScopeAnalysis(w, r, n) + case "DeleteNetworkInsightsAccessScopeAnalysis": + h.deleteNetworkInsightsAccessScopeAnalysis(w, r, n) + case "DescribeNetworkInsightsAccessScopeAnalyses": + h.describeNetworkInsightsAccessScopeAnalyses(w, r, n) + case "GetNetworkInsightsAccessScopeAnalysisFindings": + h.getNetworkInsightsAccessScopeAnalysisFindings(w, r, n) + default: + return false + } + + return true +} + +// ---- XML shapes ---- + +type networkInsightsPathXML struct { + NetworkInsightsPathID string `xml:"networkInsightsPathId"` + NetworkInsightsPathArn string `xml:"networkInsightsPathArn,omitempty"` + Protocol string `xml:"protocol,omitempty"` + Source string `xml:"source,omitempty"` + Destination string `xml:"destination,omitempty"` + SourceIP string `xml:"sourceIp,omitempty"` + DestinationIP string `xml:"destinationIp,omitempty"` + DestinationPort int32 `xml:"destinationPort,omitempty"` + CreatedDate string `xml:"createdDate,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type networkInsightsAnalysisXML struct { + NetworkInsightsAnalysisID string `xml:"networkInsightsAnalysisId"` + NetworkInsightsAnalysisArn string `xml:"networkInsightsAnalysisArn,omitempty"` + NetworkInsightsPathID string `xml:"networkInsightsPathId,omitempty"` + StartDate string `xml:"startDate,omitempty"` + Status string `xml:"status,omitempty"` + StatusMessage string `xml:"statusMessage,omitempty"` + NetworkPathFound bool `xml:"networkPathFound"` + FilterInArns []string `xml:"filterInArnSet>item,omitempty"` + FilterOutArns []string `xml:"filterOutArnSet>item,omitempty"` + AdditionalAccounts []string `xml:"additionalAccountSet>item,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type accessScopeResourceStatementXML struct { + ResourceTypes []string `xml:"resourceTypeSet>item,omitempty"` + Resources []string `xml:"resourceSet>item,omitempty"` +} + +type accessScopeStatementXML struct { + ResourceStatement *accessScopeResourceStatementXML `xml:"resourceStatement,omitempty"` +} + +type accessScopePathXML struct { + Source *accessScopeStatementXML `xml:"source,omitempty"` + Destination *accessScopeStatementXML `xml:"destination,omitempty"` +} + +type networkInsightsAccessScopeXML struct { + NetworkInsightsAccessScopeID string `xml:"networkInsightsAccessScopeId"` + NetworkInsightsAccessScopeArn string `xml:"networkInsightsAccessScopeArn,omitempty"` + CreatedDate string `xml:"createdDate,omitempty"` + UpdatedDate string `xml:"updatedDate,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type networkInsightsAccessScopeContentXML struct { + NetworkInsightsAccessScopeID string `xml:"networkInsightsAccessScopeId"` + MatchPaths []accessScopePathXML `xml:"matchPathSet>item,omitempty"` + ExcludePaths []accessScopePathXML `xml:"excludePathSet>item,omitempty"` +} + +type networkInsightsAccessScopeAnalysisXML struct { + NetworkInsightsAccessScopeAnalysisID string `xml:"networkInsightsAccessScopeAnalysisId"` + NetworkInsightsAccessScopeAnalysisArn string `xml:"networkInsightsAccessScopeAnalysisArn,omitempty"` + NetworkInsightsAccessScopeID string `xml:"networkInsightsAccessScopeId,omitempty"` + Status string `xml:"status,omitempty"` + StatusMessage string `xml:"statusMessage,omitempty"` + StartDate string `xml:"startDate,omitempty"` + EndDate string `xml:"endDate,omitempty"` + FindingsFound string `xml:"findingsFound,omitempty"` + AnalyzedEniCount int32 `xml:"analyzedEniCount,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type accessScopeAnalysisFindingXML struct { + FindingID string `xml:"findingId,omitempty"` + NetworkInsightsAccessScopeAnalysisID string `xml:"networkInsightsAccessScopeAnalysisId,omitempty"` + NetworkInsightsAccessScopeID string `xml:"networkInsightsAccessScopeId,omitempty"` +} + +// ---- path handlers ---- + +func (*Handler) createNetworkInsightsPath(w http.ResponseWriter, r *http.Request, n netdriver.NetworkInsights) { + out, err := n.CreateNetworkInsightsPath(r.Context(), netdriver.NetworkInsightsPathConfig{ + Protocol: r.Form.Get("Protocol"), + Source: r.Form.Get("Source"), + Destination: r.Form.Get("Destination"), + SourceIP: r.Form.Get("SourceIp"), + DestinationIP: r.Form.Get("DestinationIp"), + DestinationPort: formInt32(r, "DestinationPort"), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "network-insights-path"), + }) + if err != nil { + writeNetworkInsightsErr(w, err, "InvalidNetworkInsightsPathId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateNetworkInsightsPathResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Path networkInsightsPathXML `xml:"networkInsightsPath"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Path: toPathXML(out)}) +} + +func (*Handler) deleteNetworkInsightsPath(w http.ResponseWriter, r *http.Request, n netdriver.NetworkInsights) { + id := r.Form.Get("NetworkInsightsPathId") + if err := n.DeleteNetworkInsightsPath(r.Context(), id); err != nil { + writeNetworkInsightsErr(w, err, "InvalidNetworkInsightsPathId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteNetworkInsightsPathResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + ID string `xml:"networkInsightsPathId"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, ID: id}) +} + +//nolint:dupl // parallel per-resource wire dispatch/marshaling +func (*Handler) describeNetworkInsightsPaths(w http.ResponseWriter, r *http.Request, n netdriver.NetworkInsights) { + items, err := n.DescribeNetworkInsightsPaths(r.Context(), awsquery.ListStrings(r.Form, "NetworkInsightsPathId")) + if err != nil { + writeNetworkInsightsErr(w, err, "InvalidNetworkInsightsPathId.NotFound") + return + } + + out := make([]networkInsightsPathXML, 0, len(items)) + for i := range items { + out = append(out, toPathXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeNetworkInsightsPathsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []networkInsightsPathXML `xml:"networkInsightsPathSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) startNetworkInsightsAnalysis(w http.ResponseWriter, r *http.Request, n netdriver.NetworkInsights) { + out, err := n.StartNetworkInsightsAnalysis(r.Context(), netdriver.NetworkInsightsAnalysisConfig{ + PathID: r.Form.Get("NetworkInsightsPathId"), + FilterInARNs: awsquery.ListStrings(r.Form, "FilterInArn"), + FilterOutARNs: awsquery.ListStrings(r.Form, "FilterOutArn"), + AdditionalAccounts: awsquery.ListStrings(r.Form, "AdditionalAccount"), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "network-insights-analysis"), + }) + if err != nil { + writeNetworkInsightsErr(w, err, "InvalidNetworkInsightsPathId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"StartNetworkInsightsAnalysisResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Analysis networkInsightsAnalysisXML `xml:"networkInsightsAnalysis"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Analysis: toAnalysisXML(out)}) +} + +func (*Handler) deleteNetworkInsightsAnalysis(w http.ResponseWriter, r *http.Request, n netdriver.NetworkInsights) { + id := r.Form.Get("NetworkInsightsAnalysisId") + if err := n.DeleteNetworkInsightsAnalysis(r.Context(), id); err != nil { + writeNetworkInsightsErr(w, err, "InvalidNetworkInsightsAnalysisId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteNetworkInsightsAnalysisResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + ID string `xml:"networkInsightsAnalysisId"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, ID: id}) +} + +//nolint:dupl // parallel per-resource wire dispatch/marshaling +func (*Handler) describeNetworkInsightsAnalyses(w http.ResponseWriter, r *http.Request, n netdriver.NetworkInsights) { + items, err := n.DescribeNetworkInsightsAnalyses(r.Context(), + awsquery.ListStrings(r.Form, "NetworkInsightsAnalysisId"), + r.Form.Get("NetworkInsightsPathId")) + if err != nil { + writeNetworkInsightsErr(w, err, "InvalidNetworkInsightsAnalysisId.NotFound") + return + } + + out := make([]networkInsightsAnalysisXML, 0, len(items)) + for i := range items { + out = append(out, toAnalysisXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeNetworkInsightsAnalysesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []networkInsightsAnalysisXML `xml:"networkInsightsAnalysisSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +// ---- access-scope handlers ---- + +func (*Handler) createNetworkInsightsAccessScope(w http.ResponseWriter, r *http.Request, n netdriver.NetworkInsights) { + out, err := n.CreateNetworkInsightsAccessScope(r.Context(), netdriver.NetworkInsightsAccessScopeConfig{ + MatchPaths: parseAccessScopePaths(r, "MatchPath"), + ExcludePaths: parseAccessScopePaths(r, "ExcludePath"), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "network-insights-access-scope"), + }) + if err != nil { + writeNetworkInsightsErr(w, err, "InvalidNetworkInsightsAccessScopeId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateNetworkInsightsAccessScopeResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Scope networkInsightsAccessScopeXML `xml:"networkInsightsAccessScope"` + Content networkInsightsAccessScopeContentXML `xml:"networkInsightsAccessScopeContent"` + }{ + Xmlns: awsquery.Namespace, Req: awsquery.RequestID, + Scope: toAccessScopeXML(out), Content: toAccessScopeContentXML(out), + }) +} + +func (*Handler) deleteNetworkInsightsAccessScope(w http.ResponseWriter, r *http.Request, n netdriver.NetworkInsights) { + id := r.Form.Get("NetworkInsightsAccessScopeId") + if err := n.DeleteNetworkInsightsAccessScope(r.Context(), id); err != nil { + writeNetworkInsightsErr(w, err, "InvalidNetworkInsightsAccessScopeId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteNetworkInsightsAccessScopeResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + ID string `xml:"networkInsightsAccessScopeId"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, ID: id}) +} + +//nolint:dupl // parallel per-resource wire dispatch/marshaling +func (*Handler) describeNetworkInsightsAccessScopes( + w http.ResponseWriter, r *http.Request, n netdriver.NetworkInsights, +) { + items, err := n.DescribeNetworkInsightsAccessScopes(r.Context(), + awsquery.ListStrings(r.Form, "NetworkInsightsAccessScopeId")) + if err != nil { + writeNetworkInsightsErr(w, err, "InvalidNetworkInsightsAccessScopeId.NotFound") + return + } + + out := make([]networkInsightsAccessScopeXML, 0, len(items)) + for i := range items { + out = append(out, toAccessScopeXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeNetworkInsightsAccessScopesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []networkInsightsAccessScopeXML `xml:"networkInsightsAccessScopeSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) getNetworkInsightsAccessScopeContent( + w http.ResponseWriter, r *http.Request, n netdriver.NetworkInsights, +) { + out, err := n.GetNetworkInsightsAccessScopeContent(r.Context(), r.Form.Get("NetworkInsightsAccessScopeId")) + if err != nil { + writeNetworkInsightsErr(w, err, "InvalidNetworkInsightsAccessScopeId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetNetworkInsightsAccessScopeContentResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Content networkInsightsAccessScopeContentXML `xml:"networkInsightsAccessScopeContent"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Content: toAccessScopeContentXML(out)}) +} + +//nolint:dupl // parallel per-resource wire dispatch/marshaling +func (*Handler) startNetworkInsightsAccessScopeAnalysis( + w http.ResponseWriter, r *http.Request, n netdriver.NetworkInsights, +) { + out, err := n.StartNetworkInsightsAccessScopeAnalysis(r.Context(), + r.Form.Get("NetworkInsightsAccessScopeId"), + mergeTagSpecs(awsquery.TagSpecs(r.Form), "network-insights-access-scope-analysis")) + if err != nil { + writeNetworkInsightsErr(w, err, "InvalidNetworkInsightsAccessScopeId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"StartNetworkInsightsAccessScopeAnalysisResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Analysis networkInsightsAccessScopeAnalysisXML `xml:"networkInsightsAccessScopeAnalysis"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Analysis: toAccessScopeAnalysisXML(out)}) +} + +func (*Handler) deleteNetworkInsightsAccessScopeAnalysis( + w http.ResponseWriter, r *http.Request, n netdriver.NetworkInsights, +) { + id := r.Form.Get("NetworkInsightsAccessScopeAnalysisId") + if err := n.DeleteNetworkInsightsAccessScopeAnalysis(r.Context(), id); err != nil { + writeNetworkInsightsErr(w, err, "InvalidNetworkInsightsAccessScopeAnalysisId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteNetworkInsightsAccessScopeAnalysisResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + ID string `xml:"networkInsightsAccessScopeAnalysisId"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, ID: id}) +} + +//nolint:dupl // parallel per-resource wire dispatch/marshaling +func (*Handler) describeNetworkInsightsAccessScopeAnalyses( + w http.ResponseWriter, r *http.Request, n netdriver.NetworkInsights, +) { + items, err := n.DescribeNetworkInsightsAccessScopeAnalyses(r.Context(), + awsquery.ListStrings(r.Form, "NetworkInsightsAccessScopeAnalysisId"), + r.Form.Get("NetworkInsightsAccessScopeId")) + if err != nil { + writeNetworkInsightsErr(w, err, "InvalidNetworkInsightsAccessScopeAnalysisId.NotFound") + return + } + + out := make([]networkInsightsAccessScopeAnalysisXML, 0, len(items)) + for i := range items { + out = append(out, toAccessScopeAnalysisXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeNetworkInsightsAccessScopeAnalysesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []networkInsightsAccessScopeAnalysisXML `xml:"networkInsightsAccessScopeAnalysisSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) getNetworkInsightsAccessScopeAnalysisFindings( + w http.ResponseWriter, r *http.Request, n netdriver.NetworkInsights, +) { + findings, status, err := n.GetNetworkInsightsAccessScopeAnalysisFindings(r.Context(), + r.Form.Get("NetworkInsightsAccessScopeAnalysisId")) + if err != nil { + writeNetworkInsightsErr(w, err, "InvalidNetworkInsightsAccessScopeAnalysisId.NotFound") + return + } + + out := make([]accessScopeAnalysisFindingXML, 0, len(findings)) + for i := range findings { + out = append(out, accessScopeAnalysisFindingXML{ + FindingID: findings[i].FindingID, + NetworkInsightsAccessScopeAnalysisID: findings[i].AnalysisID, + NetworkInsightsAccessScopeID: findings[i].AccessScopeID, + }) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetNetworkInsightsAccessScopeAnalysisFindingsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + AnalysisID string `xml:"networkInsightsAccessScopeAnalysisId,omitempty"` + Status string `xml:"analysisStatus,omitempty"` + Findings []accessScopeAnalysisFindingXML `xml:"analysisFindingSet>item,omitempty"` + }{ + Xmlns: awsquery.Namespace, Req: awsquery.RequestID, + AnalysisID: r.Form.Get("NetworkInsightsAccessScopeAnalysisId"), Status: status, Findings: out, + }) +} + +// ---- request parsing ---- + +// parseAccessScopePaths reads MatchPath.N / ExcludePath.N groups, each carrying +// Source/Destination ResourceStatement resource-type and resource lists. +func parseAccessScopePaths(r *http.Request, prefix string) []netdriver.AccessScopePath { + indices := awsquery.CollectIndices(r.Form, prefix) + if len(indices) == 0 { + return nil + } + + out := make([]netdriver.AccessScopePath, 0, len(indices)) + + for _, idx := range indices { + base := prefix + "." + strconv.Itoa(idx) + out = append(out, netdriver.AccessScopePath{ + Source: parseAccessScopeStatement(r, base+".Source"), + Destination: parseAccessScopeStatement(r, base+".Destination"), + }) + } + + return out +} + +func parseAccessScopeStatement(r *http.Request, base string) *netdriver.AccessScopeStatement { + types := awsquery.ListStrings(r.Form, base+".ResourceStatement.ResourceType") + resources := awsquery.ListStrings(r.Form, base+".ResourceStatement.Resource") + + if len(types) == 0 && len(resources) == 0 { + return nil + } + + return &netdriver.AccessScopeStatement{ + ResourceStatement: &netdriver.AccessScopeResourceStatement{ + ResourceTypes: types, + Resources: resources, + }, + } +} + +// ---- driver → XML ---- + +func toPathXML(p *netdriver.NetworkInsightsPath) networkInsightsPathXML { + return networkInsightsPathXML{ + NetworkInsightsPathID: p.ID, + NetworkInsightsPathArn: p.ARN, + Protocol: p.Protocol, + Source: p.Source, + Destination: p.Destination, + SourceIP: p.SourceIP, + DestinationIP: p.DestinationIP, + DestinationPort: p.DestinationPort, + CreatedDate: formatTime(p.CreatedDate), + Tags: toTagItems(p.Tags), + } +} + +func toAnalysisXML(a *netdriver.NetworkInsightsAnalysis) networkInsightsAnalysisXML { + return networkInsightsAnalysisXML{ + NetworkInsightsAnalysisID: a.ID, + NetworkInsightsAnalysisArn: a.ARN, + NetworkInsightsPathID: a.PathID, + StartDate: formatTime(a.StartDate), + Status: a.Status, + StatusMessage: a.StatusMessage, + NetworkPathFound: a.NetworkPathFound, + FilterInArns: a.FilterInARNs, + FilterOutArns: a.FilterOutARNs, + AdditionalAccounts: a.AdditionalAccounts, + Tags: toTagItems(a.Tags), + } +} + +func toAccessScopeXML(s *netdriver.NetworkInsightsAccessScope) networkInsightsAccessScopeXML { + return networkInsightsAccessScopeXML{ + NetworkInsightsAccessScopeID: s.ID, + NetworkInsightsAccessScopeArn: s.ARN, + CreatedDate: formatTime(s.CreatedDate), + UpdatedDate: formatTime(s.UpdatedDate), + Tags: toTagItems(s.Tags), + } +} + +func toAccessScopeContentXML(s *netdriver.NetworkInsightsAccessScope) networkInsightsAccessScopeContentXML { + return networkInsightsAccessScopeContentXML{ + NetworkInsightsAccessScopeID: s.ID, + MatchPaths: toAccessScopePathXMLs(s.MatchPaths), + ExcludePaths: toAccessScopePathXMLs(s.ExcludePaths), + } +} + +func toAccessScopePathXMLs(paths []netdriver.AccessScopePath) []accessScopePathXML { + if len(paths) == 0 { + return nil + } + + out := make([]accessScopePathXML, 0, len(paths)) + for i := range paths { + out = append(out, accessScopePathXML{ + Source: toAccessScopeStatementXML(paths[i].Source), + Destination: toAccessScopeStatementXML(paths[i].Destination), + }) + } + + return out +} + +func toAccessScopeStatementXML(s *netdriver.AccessScopeStatement) *accessScopeStatementXML { + if s == nil || s.ResourceStatement == nil { + return nil + } + + return &accessScopeStatementXML{ + ResourceStatement: &accessScopeResourceStatementXML{ + ResourceTypes: s.ResourceStatement.ResourceTypes, + Resources: s.ResourceStatement.Resources, + }, + } +} + +func toAccessScopeAnalysisXML(a *netdriver.NetworkInsightsAccessScopeAnalysis) networkInsightsAccessScopeAnalysisXML { + return networkInsightsAccessScopeAnalysisXML{ + NetworkInsightsAccessScopeAnalysisID: a.ID, + NetworkInsightsAccessScopeAnalysisArn: a.ARN, + NetworkInsightsAccessScopeID: a.AccessScopeID, + Status: a.Status, + StatusMessage: a.StatusMessage, + StartDate: formatTime(a.StartDate), + EndDate: formatTime(a.EndDate), + FindingsFound: a.FindingsFound, + AnalyzedEniCount: a.AnalyzedEniCount, + Tags: toTagItems(a.Tags), + } +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + + return t.Format(time.RFC3339) +} + +func writeNetworkInsightsErr(w http.ResponseWriter, err error, notFoundCode string) { + writeErrWithNotFound(w, err, notFoundCode, "DependencyViolation") +} diff --git a/server/aws/ec2/network_interface.go b/server/aws/ec2/network_interface.go index 9922d16c..dcea9e31 100644 --- a/server/aws/ec2/network_interface.go +++ b/server/aws/ec2/network_interface.go @@ -31,6 +31,13 @@ type describeNetworkInterfacesResponseXML struct { NetworkInterfaceSet []networkInterfaceXML `xml:"networkInterfaceSet>item"` } +type createNetworkInterfaceResponseXML struct { + XMLName xml.Name `xml:"CreateNetworkInterfaceResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + NetworkInterface networkInterfaceXML `xml:"networkInterface"` +} + type detachNetworkInterfaceResponseXML struct { XMLName xml.Name `xml:"DetachNetworkInterfaceResponse"` Xmlns string `xml:"xmlns,attr"` @@ -150,6 +157,33 @@ func containsString(values []string, want string) bool { return false } +func (h *Handler) createNetworkInterface(w http.ResponseWriter, r *http.Request) { + creator, ok := h.vpc.(netdriver.NetworkInterfaceCreator) + if !ok { + writeUnsupportedENI(w) + return + } + + subnetID := r.Form.Get("SubnetId") + if subnetID == "" { + writeENIErr(w, cerrors.New(cerrors.InvalidArgument, "SubnetId is required")) + return + } + + eni, err := creator.CreateNetworkInterface(r.Context(), subnetID, r.Form.Get("Description"), + mergeTagSpecs(awsquery.TagSpecs(r.Form), "network-interface")) + if err != nil { + writeENIErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, createNetworkInterfaceResponseXML{ + Xmlns: awsquery.Namespace, + RequestID: awsquery.RequestID, + NetworkInterface: toNetworkInterfaceXML(eni), + }) +} + func (h *Handler) detachNetworkInterface(w http.ResponseWriter, r *http.Request) { force := r.Form.Get("Force") == formTrue diff --git a/server/aws/ec2/networking_common.go b/server/aws/ec2/networking_common.go new file mode 100644 index 00000000..292fbc81 --- /dev/null +++ b/server/aws/ec2/networking_common.go @@ -0,0 +1,26 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" +) + +// writeReturnTrue writes the common EC2 "true" acknowledgement +// with a caller-supplied response root element (set at runtime via xml.Name). +// SDK output shapes ignore unknown fields, so a return element is harmless even +// for actions whose real response carries only a requestId. +func writeReturnTrue(w http.ResponseWriter, rootElement string) { + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + Return bool `xml:"return"` + }{ + XMLName: xml.Name{Local: rootElement}, + Xmlns: awsquery.Namespace, + RequestID: awsquery.RequestID, + Return: true, + }) +} diff --git a/server/aws/ec2/operations.go b/server/aws/ec2/operations.go index c8152819..dc0b6ae0 100644 --- a/server/aws/ec2/operations.go +++ b/server/aws/ec2/operations.go @@ -116,7 +116,7 @@ func (h *Handler) stopInstances(w http.ResponseWriter, r *http.Request) { RequestID: awsquery.RequestID, Changes: stateChanges(ids, instanceState{Code: stateCodeStopping, Name: "stopping"}, - instanceState{Code: stateCodeRunning, Name: "running"}), + instanceState{Code: stateCodeRunning, Name: stateRunning}), }) } @@ -150,7 +150,7 @@ func (h *Handler) terminateInstances(w http.ResponseWriter, r *http.Request) { RequestID: awsquery.RequestID, Changes: stateChanges(ids, instanceState{Code: stateCodeShuttingDown, Name: "shutting-down"}, - instanceState{Code: stateCodeRunning, Name: "running"}), + instanceState{Code: stateCodeRunning, Name: stateRunning}), }) } diff --git a/server/aws/ec2/prefix_list.go b/server/aws/ec2/prefix_list.go new file mode 100644 index 00000000..d3936613 --- /dev/null +++ b/server/aws/ec2/prefix_list.go @@ -0,0 +1,233 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + "strconv" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) prefixLists() (netdriver.PrefixLists, bool) { + p, ok := h.vpc.(netdriver.PrefixLists) + + return p, ok +} + +type prefixListXML struct { + PrefixListID string `xml:"prefixListId"` + PrefixListName string `xml:"prefixListName"` + AddressFamily string `xml:"addressFamily"` + MaxEntries int `xml:"maxEntries"` + State string `xml:"state"` + Version int `xml:"version"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type prefixListEntryXML struct { + Cidr string `xml:"cidr"` + Description string `xml:"description,omitempty"` +} + +func (h *Handler) routePrefixLists(w http.ResponseWriter, r *http.Request, action string) bool { + p, ok := h.prefixLists() + if !ok { + return false + } + + switch action { + case "CreateManagedPrefixList": + h.createPrefixList(w, r, p) + case "DeleteManagedPrefixList": + h.deletePrefixList(w, r, p) + case "DescribeManagedPrefixLists": + h.describePrefixLists(w, r, p) + case "GetManagedPrefixListEntries": + h.getPrefixListEntries(w, r, p) + case "ModifyManagedPrefixList": + h.modifyPrefixList(w, r, p) + default: + return false + } + + return true +} + +func (*Handler) createPrefixList(w http.ResponseWriter, r *http.Request, p netdriver.PrefixLists) { + maxEntries, _ := strconv.Atoi(r.Form.Get("MaxEntries")) + + out, err := p.CreateManagedPrefixList(r.Context(), netdriver.PrefixListConfig{ + Name: r.Form.Get("PrefixListName"), + AddressFamily: r.Form.Get("AddressFamily"), + MaxEntries: maxEntries, + Entries: parsePrefixListEntries(r), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "prefix-list"), + }) + if err != nil { + writePrefixListErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateManagedPrefixListResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + PL prefixListXML `xml:"prefixList"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, PL: toPrefixListXML(out)}) +} + +func (*Handler) deletePrefixList(w http.ResponseWriter, r *http.Request, p netdriver.PrefixLists) { + out, err := p.DeleteManagedPrefixList(r.Context(), r.Form.Get("PrefixListId")) + if err != nil { + writePrefixListErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteManagedPrefixListResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + PL prefixListXML `xml:"prefixList"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, PL: toPrefixListXML(out)}) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describePrefixLists(w http.ResponseWriter, r *http.Request, p netdriver.PrefixLists) { + items, err := p.DescribeManagedPrefixLists(r.Context(), awsquery.ListStrings(r.Form, "PrefixListId")) + if err != nil { + writePrefixListErr(w, err) + return + } + + out := make([]prefixListXML, 0, len(items)) + for i := range items { + out = append(out, toPrefixListXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeManagedPrefixListsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []prefixListXML `xml:"prefixListSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) getPrefixListEntries(w http.ResponseWriter, r *http.Request, p netdriver.PrefixLists) { + entries, err := p.GetManagedPrefixListEntries(r.Context(), r.Form.Get("PrefixListId")) + if err != nil { + writePrefixListErr(w, err) + return + } + + out := make([]prefixListEntryXML, 0, len(entries)) + for i := range entries { + out = append(out, prefixListEntryXML{Cidr: entries[i].CIDR, Description: entries[i].Description}) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"GetManagedPrefixListEntriesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []prefixListEntryXML `xml:"entrySet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) modifyPrefixList(w http.ResponseWriter, r *http.Request, p netdriver.PrefixLists) { + out, err := p.ModifyManagedPrefixList(r.Context(), + r.Form.Get("PrefixListId"), parseAddPrefixListEntries(r), parseRemovePrefixListCIDRs(r)) + if err != nil { + writePrefixListErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"ModifyManagedPrefixListResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + PL prefixListXML `xml:"prefixList"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, PL: toPrefixListXML(out)}) +} + +func parseAddPrefixListEntries(r *http.Request) []netdriver.PrefixListEntry { + var out []netdriver.PrefixListEntry + + for _, prefix := range []string{"AddEntry", "AddEntries"} { + for i := 1; ; i++ { + base := prefix + "." + strconv.Itoa(i) + + cidr := r.Form.Get(base + ".Cidr") + if cidr == "" { + break + } + + out = append(out, netdriver.PrefixListEntry{CIDR: cidr, Description: r.Form.Get(base + ".Description")}) + } + + if len(out) > 0 { + return out + } + } + + return out +} + +func parseRemovePrefixListCIDRs(r *http.Request) []string { + var out []string + + for _, prefix := range []string{"RemoveEntry", "RemoveEntries"} { + for i := 1; ; i++ { + base := prefix + "." + strconv.Itoa(i) + + cidr := r.Form.Get(base + ".Cidr") + if cidr == "" { + break + } + + out = append(out, cidr) + } + + if len(out) > 0 { + return out + } + } + + return out +} + +// parsePrefixListEntries reads the AddPrefixListEntry list. The EC2 query +// serialization names the member "Entry" (Entry.N.Cidr); older/alternate SDKs +// may use "Entries", so both prefixes are accepted. +func parsePrefixListEntries(r *http.Request) []netdriver.PrefixListEntry { + for _, prefix := range []string{"Entry", "Entries"} { + var out []netdriver.PrefixListEntry + + for i := 1; ; i++ { + base := prefix + "." + strconv.Itoa(i) + + cidr := r.Form.Get(base + ".Cidr") + if cidr == "" { + break + } + + out = append(out, netdriver.PrefixListEntry{CIDR: cidr, Description: r.Form.Get(base + ".Description")}) + } + + if len(out) > 0 { + return out + } + } + + return nil +} + +func toPrefixListXML(p *netdriver.PrefixList) prefixListXML { + return prefixListXML{ + PrefixListID: p.ID, PrefixListName: p.Name, AddressFamily: p.AddressFamily, + MaxEntries: p.MaxEntries, State: p.State, Version: p.Version, Tags: toTagItems(p.Tags), + } +} + +func writePrefixListErr(w http.ResponseWriter, err error) { + writeErrWithNotFound(w, err, "InvalidPrefixListID.NotFound", "IncorrectState") +} diff --git a/server/aws/ec2/tags.go b/server/aws/ec2/tags.go new file mode 100644 index 00000000..6d7fbda6 --- /dev/null +++ b/server/aws/ec2/tags.go @@ -0,0 +1,114 @@ +package ec2 + +import ( + "context" + "encoding/xml" + "net/http" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" +) + +// computeTagger is the AWS-specific compute-resource tagging surface +// (instances/volumes/snapshots/images). It's not part of the portable Compute +// driver (Azure/GCP also implement it), so the handler type-asserts for it. +type computeTagger interface { + TagResource(ctx context.Context, id string, tags map[string]string) error + UntagResource(ctx context.Context, id string, keys []string) error +} + +type tagsResponseXML struct { + XMLName xml.Name `xml:"CreateTagsResponse"` + Return bool `xml:"return"` + RequestID string `xml:"requestId"` +} + +type deleteTagsResponseXML struct { + XMLName xml.Name `xml:"DeleteTagsResponse"` + Return bool `xml:"return"` + RequestID string `xml:"requestId"` +} + +func (h *Handler) routeTags(w http.ResponseWriter, r *http.Request, action string) bool { + switch action { + case "CreateTags": + h.createTags(w, r) + case "DeleteTags": + h.deleteTags(w, r) + default: + return false + } + + return true +} + +// createTags applies tags to one or more resources, dispatching each resource +// ID by prefix to the owning provider (VPC-family IDs to the networking +// provider, compute IDs to the compute tagger). +func (h *Handler) createTags(w http.ResponseWriter, r *http.Request) { + ids := awsquery.ListStrings(r.Form, "ResourceId") + tags := awsquery.FlatTags(r.Form, "Tag") + + for _, id := range ids { + if err := h.tagResource(r.Context(), id, tags); err != nil { + writeErrWithNotFound(w, err, "InvalidID.NotFound", "IncorrectState") + return + } + } + + awsquery.WriteXMLResponse(w, tagsResponseXML{Return: true, RequestID: "cloudemu"}) +} + +// deleteTags removes tags (by key) from one or more resources. +func (h *Handler) deleteTags(w http.ResponseWriter, r *http.Request) { + ids := awsquery.ListStrings(r.Form, "ResourceId") + tags := awsquery.FlatTags(r.Form, "Tag") + + keys := make([]string, 0, len(tags)) + for k := range tags { + keys = append(keys, k) + } + + for _, id := range ids { + if err := h.untagResource(r.Context(), id, keys); err != nil { + writeErrWithNotFound(w, err, "InvalidID.NotFound", "IncorrectState") + return + } + } + + awsquery.WriteXMLResponse(w, deleteTagsResponseXML{Return: true, RequestID: "cloudemu"}) +} + +func (h *Handler) tagResource(ctx context.Context, id string, tags map[string]string) error { + switch { + case strings.HasPrefix(id, "vpc-"): + return h.vpc.UpdateVPCTags(ctx, id, tags) + case strings.HasPrefix(id, "subnet-"): + return h.vpc.UpdateSubnetTags(ctx, id, tags) + case strings.HasPrefix(id, "sg-"): + return h.vpc.UpdateSecurityGroupTags(ctx, id, tags) + default: + if tagger, ok := h.compute.(computeTagger); ok { + return tagger.TagResource(ctx, id, tags) + } + + return nil + } +} + +func (h *Handler) untagResource(ctx context.Context, id string, keys []string) error { + switch { + case strings.HasPrefix(id, "vpc-"): + return h.vpc.RemoveVPCTags(ctx, id, keys) + case strings.HasPrefix(id, "subnet-"): + return h.vpc.RemoveSubnetTags(ctx, id, keys) + case strings.HasPrefix(id, "sg-"): + return h.vpc.RemoveSecurityGroupTags(ctx, id, keys) + default: + if tagger, ok := h.compute.(computeTagger); ok { + return tagger.UntagResource(ctx, id, keys) + } + + return nil + } +} diff --git a/server/aws/ec2/traffic_mirror.go b/server/aws/ec2/traffic_mirror.go new file mode 100644 index 00000000..a657f67d --- /dev/null +++ b/server/aws/ec2/traffic_mirror.go @@ -0,0 +1,563 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + "strconv" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) trafficMirroring() (netdriver.TrafficMirroring, bool) { + t, ok := h.vpc.(netdriver.TrafficMirroring) + + return t, ok +} + +func (h *Handler) routeTrafficMirroring(w http.ResponseWriter, r *http.Request, action string) bool { + t, ok := h.trafficMirroring() + if !ok { + return false + } + + if h.routeTrafficMirrorTargets(w, r, action, t) { + return true + } + + if h.routeTrafficMirrorFilters(w, r, action, t) { + return true + } + + return h.routeTrafficMirrorSessions(w, r, action, t) +} + +func (h *Handler) routeTrafficMirrorTargets( + w http.ResponseWriter, r *http.Request, action string, t netdriver.TrafficMirroring, +) bool { + switch action { + case "CreateTrafficMirrorTarget": + h.createTrafficMirrorTarget(w, r, t) + case "DeleteTrafficMirrorTarget": + h.deleteTrafficMirrorTarget(w, r, t) + case "DescribeTrafficMirrorTargets": + h.describeTrafficMirrorTargets(w, r, t) + default: + return false + } + + return true +} + +//nolint:dupl // parallel per-resource wire dispatch/marshaling +func (h *Handler) routeTrafficMirrorFilters( + w http.ResponseWriter, r *http.Request, action string, t netdriver.TrafficMirroring, +) bool { + switch action { + case "CreateTrafficMirrorFilter": + h.createTrafficMirrorFilter(w, r, t) + case "DeleteTrafficMirrorFilter": + h.deleteTrafficMirrorFilter(w, r, t) + case "DescribeTrafficMirrorFilters": + h.describeTrafficMirrorFilters(w, r, t) + case "ModifyTrafficMirrorFilterNetworkServices": + h.modifyTrafficMirrorFilterNetworkServices(w, r, t) + case "CreateTrafficMirrorFilterRule": + h.createTrafficMirrorFilterRule(w, r, t) + case "ModifyTrafficMirrorFilterRule": + h.modifyTrafficMirrorFilterRule(w, r, t) + case "DeleteTrafficMirrorFilterRule": + h.deleteTrafficMirrorFilterRule(w, r, t) + case "DescribeTrafficMirrorFilterRules": + h.describeTrafficMirrorFilterRules(w, r, t) + default: + return false + } + + return true +} + +func (h *Handler) routeTrafficMirrorSessions( + w http.ResponseWriter, r *http.Request, action string, t netdriver.TrafficMirroring, +) bool { + switch action { + case "CreateTrafficMirrorSession": + h.createTrafficMirrorSession(w, r, t) + case "ModifyTrafficMirrorSession": + h.modifyTrafficMirrorSession(w, r, t) + case "DeleteTrafficMirrorSession": + h.deleteTrafficMirrorSession(w, r, t) + case "DescribeTrafficMirrorSessions": + h.describeTrafficMirrorSessions(w, r, t) + default: + return false + } + + return true +} + +// ---- XML shapes ---- + +type trafficMirrorTargetXML struct { + TrafficMirrorTargetID string `xml:"trafficMirrorTargetId"` + NetworkInterfaceID string `xml:"networkInterfaceId,omitempty"` + NetworkLoadBalancerArn string `xml:"networkLoadBalancerArn,omitempty"` + GatewayLoadBalancerEndpointID string `xml:"gatewayLoadBalancerEndpointId,omitempty"` + Type string `xml:"type,omitempty"` + Description string `xml:"description,omitempty"` + OwnerID string `xml:"ownerId,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type trafficMirrorPortRangeXML struct { + FromPort int32 `xml:"fromPort"` + ToPort int32 `xml:"toPort"` +} + +type trafficMirrorFilterRuleXML struct { + TrafficMirrorFilterRuleID string `xml:"trafficMirrorFilterRuleId"` + TrafficMirrorFilterID string `xml:"trafficMirrorFilterId"` + TrafficDirection string `xml:"trafficDirection,omitempty"` + RuleNumber int32 `xml:"ruleNumber"` + RuleAction string `xml:"ruleAction,omitempty"` + Protocol int32 `xml:"protocol,omitempty"` + DestinationCidrBlock string `xml:"destinationCidrBlock,omitempty"` + SourceCidrBlock string `xml:"sourceCidrBlock,omitempty"` + DestinationPortRange *trafficMirrorPortRangeXML `xml:"destinationPortRange,omitempty"` + SourcePortRange *trafficMirrorPortRangeXML `xml:"sourcePortRange,omitempty"` + Description string `xml:"description,omitempty"` +} + +type trafficMirrorFilterXML struct { + TrafficMirrorFilterID string `xml:"trafficMirrorFilterId"` + Description string `xml:"description,omitempty"` + IngressFilterRules []trafficMirrorFilterRuleXML `xml:"ingressFilterRuleSet>item,omitempty"` + EgressFilterRules []trafficMirrorFilterRuleXML `xml:"egressFilterRuleSet>item,omitempty"` + NetworkServices []string `xml:"networkServiceSet>item,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type trafficMirrorSessionXML struct { + TrafficMirrorSessionID string `xml:"trafficMirrorSessionId"` + TrafficMirrorTargetID string `xml:"trafficMirrorTargetId"` + TrafficMirrorFilterID string `xml:"trafficMirrorFilterId"` + NetworkInterfaceID string `xml:"networkInterfaceId,omitempty"` + PacketLength int32 `xml:"packetLength,omitempty"` + SessionNumber int32 `xml:"sessionNumber"` + VirtualNetworkID int32 `xml:"virtualNetworkId"` + Description string `xml:"description,omitempty"` + OwnerID string `xml:"ownerId,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +// ---- Target handlers ---- + +func (*Handler) createTrafficMirrorTarget(w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring) { + out, err := t.CreateTrafficMirrorTarget(r.Context(), netdriver.TrafficMirrorTargetConfig{ + Description: r.Form.Get("Description"), + NetworkInterfaceID: r.Form.Get("NetworkInterfaceId"), + NetworkLoadBalancerARN: r.Form.Get("NetworkLoadBalancerArn"), + GatewayLoadBalancerEndpointID: r.Form.Get("GatewayLoadBalancerEndpointId"), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "traffic-mirror-target"), + }) + if err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorTargetId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateTrafficMirrorTargetResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Target trafficMirrorTargetXML `xml:"trafficMirrorTarget"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Target: toTrafficMirrorTargetXML(out)}) +} + +func (*Handler) deleteTrafficMirrorTarget(w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring) { + id := r.Form.Get("TrafficMirrorTargetId") + if err := t.DeleteTrafficMirrorTarget(r.Context(), id); err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorTargetId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteTrafficMirrorTargetResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + ID string `xml:"trafficMirrorTargetId"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, ID: id}) +} + +//nolint:dupl // parallel per-resource wire dispatch/marshaling +func (*Handler) describeTrafficMirrorTargets(w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring) { + items, err := t.DescribeTrafficMirrorTargets(r.Context(), awsquery.ListStrings(r.Form, "TrafficMirrorTargetId")) + if err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorTargetId.NotFound") + return + } + + out := make([]trafficMirrorTargetXML, 0, len(items)) + for i := range items { + out = append(out, toTrafficMirrorTargetXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeTrafficMirrorTargetsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []trafficMirrorTargetXML `xml:"trafficMirrorTargetSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +// ---- Filter handlers ---- + +//nolint:dupl // parallel per-resource wire dispatch/marshaling +func (*Handler) createTrafficMirrorFilter(w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring) { + out, err := t.CreateTrafficMirrorFilter(r.Context(), r.Form.Get("Description"), + mergeTagSpecs(awsquery.TagSpecs(r.Form), "traffic-mirror-filter")) + if err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorFilterId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateTrafficMirrorFilterResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Filter trafficMirrorFilterXML `xml:"trafficMirrorFilter"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Filter: toTrafficMirrorFilterXML(out)}) +} + +func (*Handler) deleteTrafficMirrorFilter(w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring) { + id := r.Form.Get("TrafficMirrorFilterId") + if err := t.DeleteTrafficMirrorFilter(r.Context(), id); err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorFilterId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteTrafficMirrorFilterResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + ID string `xml:"trafficMirrorFilterId"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, ID: id}) +} + +//nolint:dupl // parallel per-resource wire dispatch/marshaling +func (*Handler) describeTrafficMirrorFilters(w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring) { + items, err := t.DescribeTrafficMirrorFilters(r.Context(), awsquery.ListStrings(r.Form, "TrafficMirrorFilterId")) + if err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorFilterId.NotFound") + return + } + + out := make([]trafficMirrorFilterXML, 0, len(items)) + for i := range items { + out = append(out, toTrafficMirrorFilterXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeTrafficMirrorFiltersResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []trafficMirrorFilterXML `xml:"trafficMirrorFilterSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) modifyTrafficMirrorFilterNetworkServices( + w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring, +) { + out, err := t.ModifyTrafficMirrorFilterNetworkServices(r.Context(), + r.Form.Get("TrafficMirrorFilterId"), + awsquery.ListStrings(r.Form, "AddNetworkService"), + awsquery.ListStrings(r.Form, "RemoveNetworkService")) + if err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorFilterId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"ModifyTrafficMirrorFilterNetworkServicesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Filter trafficMirrorFilterXML `xml:"trafficMirrorFilter"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Filter: toTrafficMirrorFilterXML(out)}) +} + +func (*Handler) createTrafficMirrorFilterRule(w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring) { + out, err := t.CreateTrafficMirrorFilterRule(r.Context(), trafficMirrorRuleConfig(r)) + if err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorFilterRuleId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateTrafficMirrorFilterRuleResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Rule trafficMirrorFilterRuleXML `xml:"trafficMirrorFilterRule"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Rule: toFilterRuleXML(out)}) +} + +//nolint:dupl // parallel per-resource wire dispatch/marshaling +func (*Handler) modifyTrafficMirrorFilterRule(w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring) { + out, err := t.ModifyTrafficMirrorFilterRule(r.Context(), + r.Form.Get("TrafficMirrorFilterRuleId"), + trafficMirrorRuleConfig(r), + awsquery.ListStrings(r.Form, "RemoveField")) + if err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorFilterRuleId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"ModifyTrafficMirrorFilterRuleResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Rule trafficMirrorFilterRuleXML `xml:"trafficMirrorFilterRule"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Rule: toFilterRuleXML(out)}) +} + +func (*Handler) deleteTrafficMirrorFilterRule(w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring) { + id := r.Form.Get("TrafficMirrorFilterRuleId") + if err := t.DeleteTrafficMirrorFilterRule(r.Context(), id); err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorFilterRuleId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteTrafficMirrorFilterRuleResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + ID string `xml:"trafficMirrorFilterRuleId"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, ID: id}) +} + +func (*Handler) describeTrafficMirrorFilterRules(w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring) { + items, err := t.DescribeTrafficMirrorFilterRules(r.Context(), + r.Form.Get("TrafficMirrorFilterId"), + awsquery.ListStrings(r.Form, "TrafficMirrorFilterRuleId")) + if err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorFilterRuleId.NotFound") + return + } + + out := make([]trafficMirrorFilterRuleXML, 0, len(items)) + for i := range items { + out = append(out, toFilterRuleXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeTrafficMirrorFilterRulesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []trafficMirrorFilterRuleXML `xml:"trafficMirrorFilterRuleSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +// ---- Session handlers ---- + +func (*Handler) createTrafficMirrorSession(w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring) { + out, err := t.CreateTrafficMirrorSession(r.Context(), trafficMirrorSessionConfig(r)) + if err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorTargetId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateTrafficMirrorSessionResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Session trafficMirrorSessionXML `xml:"trafficMirrorSession"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Session: toTrafficMirrorSessionXML(out)}) +} + +//nolint:dupl // parallel per-resource wire dispatch/marshaling +func (*Handler) modifyTrafficMirrorSession(w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring) { + out, err := t.ModifyTrafficMirrorSession(r.Context(), + r.Form.Get("TrafficMirrorSessionId"), + trafficMirrorSessionConfig(r), + awsquery.ListStrings(r.Form, "RemoveField")) + if err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorSessionId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"ModifyTrafficMirrorSessionResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Session trafficMirrorSessionXML `xml:"trafficMirrorSession"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Session: toTrafficMirrorSessionXML(out)}) +} + +func (*Handler) deleteTrafficMirrorSession(w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring) { + id := r.Form.Get("TrafficMirrorSessionId") + if err := t.DeleteTrafficMirrorSession(r.Context(), id); err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorSessionId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteTrafficMirrorSessionResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + ID string `xml:"trafficMirrorSessionId"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, ID: id}) +} + +//nolint:dupl // parallel per-resource wire dispatch/marshaling +func (*Handler) describeTrafficMirrorSessions(w http.ResponseWriter, r *http.Request, t netdriver.TrafficMirroring) { + items, err := t.DescribeTrafficMirrorSessions(r.Context(), awsquery.ListStrings(r.Form, "TrafficMirrorSessionId")) + if err != nil { + writeTrafficMirrorErr(w, err, "InvalidTrafficMirrorSessionId.NotFound") + return + } + + out := make([]trafficMirrorSessionXML, 0, len(items)) + for i := range items { + out = append(out, toTrafficMirrorSessionXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeTrafficMirrorSessionsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []trafficMirrorSessionXML `xml:"trafficMirrorSessionSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +// ---- request parsing ---- + +func trafficMirrorRuleConfig(r *http.Request) netdriver.TrafficMirrorFilterRuleConfig { + return netdriver.TrafficMirrorFilterRuleConfig{ + FilterID: r.Form.Get("TrafficMirrorFilterId"), + TrafficDirection: r.Form.Get("TrafficDirection"), + RuleNumber: formInt32(r, "RuleNumber"), + RuleAction: r.Form.Get("RuleAction"), + Protocol: formInt32(r, "Protocol"), + DestinationCIDR: r.Form.Get("DestinationCidrBlock"), + SourceCIDR: r.Form.Get("SourceCidrBlock"), + DestinationPortRange: formPortRange(r, "DestinationPortRange"), + SourcePortRange: formPortRange(r, "SourcePortRange"), + Description: r.Form.Get("Description"), + } +} + +func trafficMirrorSessionConfig(r *http.Request) netdriver.TrafficMirrorSessionConfig { + return netdriver.TrafficMirrorSessionConfig{ + NetworkInterfaceID: r.Form.Get("NetworkInterfaceId"), + TrafficMirrorTargetID: r.Form.Get("TrafficMirrorTargetId"), + TrafficMirrorFilterID: r.Form.Get("TrafficMirrorFilterId"), + PacketLength: formInt32(r, "PacketLength"), + SessionNumber: formInt32(r, "SessionNumber"), + VirtualNetworkID: formInt32(r, "VirtualNetworkId"), + Description: r.Form.Get("Description"), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "traffic-mirror-session"), + } +} + +func formInt32(r *http.Request, key string) int32 { + v, err := strconv.ParseInt(r.Form.Get(key), 10, 32) + if err != nil { + return 0 + } + + return int32(v) +} + +func formPortRange(r *http.Request, prefix string) *netdriver.TrafficMirrorPortRange { + from := r.Form.Get(prefix + ".FromPort") + to := r.Form.Get(prefix + ".ToPort") + + if from == "" && to == "" { + return nil + } + + return &netdriver.TrafficMirrorPortRange{ + FromPort: formInt32(r, prefix+".FromPort"), + ToPort: formInt32(r, prefix+".ToPort"), + } +} + +// ---- driver → XML ---- + +func toTrafficMirrorTargetXML(t *netdriver.TrafficMirrorTarget) trafficMirrorTargetXML { + return trafficMirrorTargetXML{ + TrafficMirrorTargetID: t.ID, + NetworkInterfaceID: t.NetworkInterfaceID, + NetworkLoadBalancerArn: t.NetworkLoadBalancerARN, + GatewayLoadBalancerEndpointID: t.GatewayLoadBalancerEndpointID, + Type: t.Type, + Description: t.Description, + OwnerID: t.OwnerID, + Tags: toTagItems(t.Tags), + } +} + +func toTrafficMirrorFilterXML(f *netdriver.TrafficMirrorFilter) trafficMirrorFilterXML { + return trafficMirrorFilterXML{ + TrafficMirrorFilterID: f.ID, + Description: f.Description, + IngressFilterRules: toFilterRuleXMLs(f.IngressRules), + EgressFilterRules: toFilterRuleXMLs(f.EgressRules), + NetworkServices: f.NetworkServices, + Tags: toTagItems(f.Tags), + } +} + +func toFilterRuleXMLs(rules []netdriver.TrafficMirrorFilterRule) []trafficMirrorFilterRuleXML { + if len(rules) == 0 { + return nil + } + + out := make([]trafficMirrorFilterRuleXML, 0, len(rules)) + for i := range rules { + out = append(out, toFilterRuleXML(&rules[i])) + } + + return out +} + +func toFilterRuleXML(r *netdriver.TrafficMirrorFilterRule) trafficMirrorFilterRuleXML { + return trafficMirrorFilterRuleXML{ + TrafficMirrorFilterRuleID: r.ID, + TrafficMirrorFilterID: r.FilterID, + TrafficDirection: r.TrafficDirection, + RuleNumber: r.RuleNumber, + RuleAction: r.RuleAction, + Protocol: r.Protocol, + DestinationCidrBlock: r.DestinationCIDR, + SourceCidrBlock: r.SourceCIDR, + DestinationPortRange: toPortRangeXML(r.DestinationPortRange), + SourcePortRange: toPortRangeXML(r.SourcePortRange), + Description: r.Description, + } +} + +func toPortRangeXML(p *netdriver.TrafficMirrorPortRange) *trafficMirrorPortRangeXML { + if p == nil { + return nil + } + + return &trafficMirrorPortRangeXML{FromPort: p.FromPort, ToPort: p.ToPort} +} + +func toTrafficMirrorSessionXML(s *netdriver.TrafficMirrorSession) trafficMirrorSessionXML { + return trafficMirrorSessionXML{ + TrafficMirrorSessionID: s.ID, + TrafficMirrorTargetID: s.TrafficMirrorTargetID, + TrafficMirrorFilterID: s.TrafficMirrorFilterID, + NetworkInterfaceID: s.NetworkInterfaceID, + PacketLength: s.PacketLength, + SessionNumber: s.SessionNumber, + VirtualNetworkID: s.VirtualNetworkID, + Description: s.Description, + OwnerID: s.OwnerID, + Tags: toTagItems(s.Tags), + } +} + +func writeTrafficMirrorErr(w http.ResponseWriter, err error, notFoundCode string) { + writeErrWithNotFound(w, err, notFoundCode, "DependencyViolation") +} diff --git a/server/aws/ec2/transit_gateway.go b/server/aws/ec2/transit_gateway.go new file mode 100644 index 00000000..0782c3fe --- /dev/null +++ b/server/aws/ec2/transit_gateway.go @@ -0,0 +1,424 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + "strconv" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +// transitGateways reports whether the driver models transit gateways (optional). +func (h *Handler) transitGateways() (netdriver.TransitGateways, bool) { + tg, ok := h.vpc.(netdriver.TransitGateways) + + return tg, ok +} + +type tgwOptionsXML struct { + AmazonSideASN int64 `xml:"amazonSideAsn"` +} + +type transitGatewayXML struct { + TransitGatewayID string `xml:"transitGatewayId"` + State string `xml:"state"` + OwnerID string `xml:"ownerId,omitempty"` + Description string `xml:"description,omitempty"` + Options tgwOptionsXML `xml:"options"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type transitGatewayAttachmentXML struct { + TransitGatewayAttachmentID string `xml:"transitGatewayAttachmentId"` + TransitGatewayID string `xml:"transitGatewayId"` + VpcID string `xml:"vpcId"` + SubnetIDs []string `xml:"subnetIds>item,omitempty"` + State string `xml:"state"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type transitGatewayRouteTableXML struct { + TransitGatewayRouteTableID string `xml:"transitGatewayRouteTableId"` + TransitGatewayID string `xml:"transitGatewayId"` + State string `xml:"state"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +//nolint:gocyclo // flat action dispatch table +func (h *Handler) routeTransitGateways(w http.ResponseWriter, r *http.Request, action string) bool { + tg, ok := h.transitGateways() + if !ok { + return false + } + + switch action { + case "CreateTransitGateway": + h.createTransitGateway(w, r, tg) + case "DeleteTransitGateway": + h.deleteTransitGateway(w, r, tg) + case "DescribeTransitGateways": + h.describeTransitGateways(w, r, tg) + case "CreateTransitGatewayVpcAttachment": + h.createTGWAttachment(w, r, tg) + case "DeleteTransitGatewayVpcAttachment": + h.deleteTGWAttachment(w, r, tg) + case "DescribeTransitGatewayVpcAttachments": + h.describeTGWAttachments(w, r, tg) + case "CreateTransitGatewayRouteTable": + h.createTGWRouteTable(w, r, tg) + case "DeleteTransitGatewayRouteTable": + h.deleteTGWRouteTable(w, r, tg) + case "DescribeTransitGatewayRouteTables": + h.describeTGWRouteTables(w, r, tg) + case "CreateTransitGatewayRoute": + h.createTGWRoute(w, r, tg) + case "DeleteTransitGatewayRoute": + h.deleteTGWRoute(w, r, tg) + case "SearchTransitGatewayRoutes": + h.searchTGWRoutes(w, r, tg) + case "AssociateTransitGatewayRouteTable": + h.associateTGWRouteTable(w, r, tg) + case "EnableTransitGatewayRouteTablePropagation": + h.setTGWPropagation(w, r, tg, true) + case "DisableTransitGatewayRouteTablePropagation": + h.setTGWPropagation(w, r, tg, false) + default: + return false + } + + return true +} + +func (*Handler) createTransitGateway(w http.ResponseWriter, r *http.Request, tg netdriver.TransitGateways) { + asn, _ := strconv.ParseInt(r.Form.Get("Options.AmazonSideAsn"), 10, 64) + + out, err := tg.CreateTransitGateway(r.Context(), netdriver.TransitGatewayConfig{ + ASN: asn, + Description: r.Form.Get("Description"), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "transit-gateway"), + }) + if err != nil { + writeTGWErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateTransitGatewayResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + TransitGateway transitGatewayXML `xml:"transitGateway"` + }{Xmlns: awsquery.Namespace, RequestID: awsquery.RequestID, TransitGateway: toTGWXML(out)}) +} + +func (*Handler) deleteTransitGateway(w http.ResponseWriter, r *http.Request, tg netdriver.TransitGateways) { + out, err := tg.DeleteTransitGateway(r.Context(), r.Form.Get("TransitGatewayId")) + if err != nil { + writeTGWErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteTransitGatewayResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + TransitGateway transitGatewayXML `xml:"transitGateway"` + }{Xmlns: awsquery.Namespace, RequestID: awsquery.RequestID, TransitGateway: toTGWXML(out)}) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeTransitGateways(w http.ResponseWriter, r *http.Request, tg netdriver.TransitGateways) { + items, err := tg.DescribeTransitGateways(r.Context(), awsquery.ListStrings(r.Form, "TransitGatewayIds")) + if err != nil { + writeTGWErr(w, err) + return + } + + out := make([]transitGatewayXML, 0, len(items)) + for i := range items { + out = append(out, toTGWXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeTransitGatewaysResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []transitGatewayXML `xml:"transitGatewaySet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) createTGWAttachment(w http.ResponseWriter, r *http.Request, tg netdriver.TransitGateways) { + out, err := tg.CreateTransitGatewayVPCAttachment(r.Context(), netdriver.TransitGatewayVPCAttachmentConfig{ + TransitGatewayID: r.Form.Get("TransitGatewayId"), + VPCID: r.Form.Get("VpcId"), + SubnetIDs: awsquery.ListStrings(r.Form, "SubnetIds"), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "transit-gateway-attachment"), + }) + if err != nil { + writeTGWErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateTransitGatewayVpcAttachmentResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + Attachment transitGatewayAttachmentXML `xml:"transitGatewayVpcAttachment"` + }{Xmlns: awsquery.Namespace, RequestID: awsquery.RequestID, Attachment: toTGWAttachmentXML(out)}) +} + +func (*Handler) deleteTGWAttachment(w http.ResponseWriter, r *http.Request, tg netdriver.TransitGateways) { + out, err := tg.DeleteTransitGatewayVPCAttachment(r.Context(), r.Form.Get("TransitGatewayAttachmentId")) + if err != nil { + writeTGWErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteTransitGatewayVpcAttachmentResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + Attachment transitGatewayAttachmentXML `xml:"transitGatewayVpcAttachment"` + }{Xmlns: awsquery.Namespace, RequestID: awsquery.RequestID, Attachment: toTGWAttachmentXML(out)}) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeTGWAttachments(w http.ResponseWriter, r *http.Request, tg netdriver.TransitGateways) { + items, err := tg.DescribeTransitGatewayVPCAttachments(r.Context(), awsquery.ListStrings(r.Form, "TransitGatewayAttachmentIds")) + if err != nil { + writeTGWErr(w, err) + return + } + + out := make([]transitGatewayAttachmentXML, 0, len(items)) + for i := range items { + out = append(out, toTGWAttachmentXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeTransitGatewayVpcAttachmentsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []transitGatewayAttachmentXML `xml:"transitGatewayVpcAttachments>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) createTGWRouteTable(w http.ResponseWriter, r *http.Request, tg netdriver.TransitGateways) { + out, err := tg.CreateTransitGatewayRouteTable(r.Context(), r.Form.Get("TransitGatewayId"), + mergeTagSpecs(awsquery.TagSpecs(r.Form), "transit-gateway-route-table")) + if err != nil { + writeTGWErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateTransitGatewayRouteTableResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + RouteTable transitGatewayRouteTableXML `xml:"transitGatewayRouteTable"` + }{Xmlns: awsquery.Namespace, RequestID: awsquery.RequestID, RouteTable: toTGWRouteTableXML(out)}) +} + +func (*Handler) deleteTGWRouteTable(w http.ResponseWriter, r *http.Request, tg netdriver.TransitGateways) { + out, err := tg.DeleteTransitGatewayRouteTable(r.Context(), r.Form.Get("TransitGatewayRouteTableId")) + if err != nil { + writeTGWErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteTransitGatewayRouteTableResponse"` + Xmlns string `xml:"xmlns,attr"` + RequestID string `xml:"requestId"` + RouteTable transitGatewayRouteTableXML `xml:"transitGatewayRouteTable"` + }{Xmlns: awsquery.Namespace, RequestID: awsquery.RequestID, RouteTable: toTGWRouteTableXML(out)}) +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeTGWRouteTables(w http.ResponseWriter, r *http.Request, tg netdriver.TransitGateways) { + items, err := tg.DescribeTransitGatewayRouteTables(r.Context(), awsquery.ListStrings(r.Form, "TransitGatewayRouteTableIds")) + if err != nil { + writeTGWErr(w, err) + return + } + + out := make([]transitGatewayRouteTableXML, 0, len(items)) + for i := range items { + out = append(out, toTGWRouteTableXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeTransitGatewayRouteTablesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []transitGatewayRouteTableXML `xml:"transitGatewayRouteTables>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func toTGWXML(t *netdriver.TransitGateway) transitGatewayXML { + return transitGatewayXML{ + TransitGatewayID: t.ID, + State: t.State, + OwnerID: t.OwnerID, + Description: t.Description, + Options: tgwOptionsXML{AmazonSideASN: t.ASN}, + Tags: toTagItems(t.Tags), + } +} + +func toTGWAttachmentXML(a *netdriver.TransitGatewayVPCAttachment) transitGatewayAttachmentXML { + return transitGatewayAttachmentXML{ + TransitGatewayAttachmentID: a.ID, + TransitGatewayID: a.TransitGatewayID, + VpcID: a.VPCID, + SubnetIDs: a.SubnetIDs, + State: a.State, + Tags: toTagItems(a.Tags), + } +} + +func toTGWRouteTableXML(t *netdriver.TransitGatewayRouteTable) transitGatewayRouteTableXML { + return transitGatewayRouteTableXML{ + TransitGatewayRouteTableID: t.ID, + TransitGatewayID: t.TransitGatewayID, + State: t.State, + Tags: toTagItems(t.Tags), + } +} + +type tgwRouteAttachmentXML struct { + TransitGatewayAttachmentID string `xml:"transitGatewayAttachmentId"` +} + +type tgwRouteXML struct { + DestinationCidrBlock string `xml:"destinationCidrBlock"` + Type string `xml:"type"` + State string `xml:"state"` + TransitGatewayAttachments []tgwRouteAttachmentXML `xml:"transitGatewayAttachments>item,omitempty"` +} + +type tgwAssociationXML struct { + TransitGatewayRouteTableID string `xml:"transitGatewayRouteTableId"` + TransitGatewayAttachmentID string `xml:"transitGatewayAttachmentId"` + ResourceID string `xml:"resourceId,omitempty"` + ResourceType string `xml:"resourceType,omitempty"` + State string `xml:"state"` +} + +func toTGWRouteXML(rt *netdriver.TransitGatewayRoute) tgwRouteXML { + x := tgwRouteXML{DestinationCidrBlock: rt.DestinationCIDR, Type: rt.Type, State: rt.State} + if rt.AttachmentID != "" { + x.TransitGatewayAttachments = []tgwRouteAttachmentXML{{TransitGatewayAttachmentID: rt.AttachmentID}} + } + + return x +} + +func (*Handler) createTGWRoute(w http.ResponseWriter, r *http.Request, tg netdriver.TransitGateways) { + out, err := tg.CreateTransitGatewayRoute(r.Context(), + r.Form.Get("TransitGatewayRouteTableId"), r.Form.Get("DestinationCidrBlock"), r.Form.Get("TransitGatewayAttachmentId")) + if err != nil { + writeTGWErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateTransitGatewayRouteResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Route tgwRouteXML `xml:"route"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Route: toTGWRouteXML(out)}) +} + +func (*Handler) deleteTGWRoute(w http.ResponseWriter, r *http.Request, tg netdriver.TransitGateways) { + out, err := tg.DeleteTransitGatewayRoute(r.Context(), + r.Form.Get("TransitGatewayRouteTableId"), r.Form.Get("DestinationCidrBlock")) + if err != nil { + writeTGWErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteTransitGatewayRouteResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Route tgwRouteXML `xml:"route"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Route: toTGWRouteXML(out)}) +} + +func (*Handler) searchTGWRoutes(w http.ResponseWriter, r *http.Request, tg netdriver.TransitGateways) { + items, err := tg.SearchTransitGatewayRoutes(r.Context(), r.Form.Get("TransitGatewayRouteTableId")) + if err != nil { + writeTGWErr(w, err) + return + } + + out := make([]tgwRouteXML, 0, len(items)) + for i := range items { + out = append(out, toTGWRouteXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"SearchTransitGatewayRoutesResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Routes []tgwRouteXML `xml:"routeSet>item"` + More string `xml:"additionalRoutesAvailable"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Routes: out, More: "false"}) +} + +func (*Handler) associateTGWRouteTable(w http.ResponseWriter, r *http.Request, tg netdriver.TransitGateways) { + out, err := tg.AssociateTransitGatewayRouteTable(r.Context(), + r.Form.Get("TransitGatewayRouteTableId"), r.Form.Get("TransitGatewayAttachmentId")) + if err != nil { + writeTGWErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"AssociateTransitGatewayRouteTableResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Association tgwAssociationXML `xml:"association"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Association: tgwAssociationXML{ + TransitGatewayRouteTableID: out.RouteTableID, TransitGatewayAttachmentID: out.AttachmentID, + ResourceID: out.ResourceID, ResourceType: out.ResourceType, State: out.State, + }}) +} + +func (*Handler) setTGWPropagation(w http.ResponseWriter, r *http.Request, tg netdriver.TransitGateways, enable bool) { + rtID := r.Form.Get("TransitGatewayRouteTableId") + attID := r.Form.Get("TransitGatewayAttachmentId") + + var err error + if enable { + err = tg.EnableTransitGatewayRouteTablePropagation(r.Context(), rtID, attID) + } else { + err = tg.DisableTransitGatewayRouteTablePropagation(r.Context(), rtID, attID) + } + + if err != nil { + writeTGWErr(w, err) + return + } + + resp, state := "EnableTransitGatewayRouteTablePropagationResponse", "enabled" + if !enable { + resp, state = "DisableTransitGatewayRouteTablePropagationResponse", "disabled" + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Propagation tgwAssociationXML `xml:"propagation"` + }{ + XMLName: xml.Name{Local: resp}, Xmlns: awsquery.Namespace, Req: awsquery.RequestID, + Propagation: tgwAssociationXML{TransitGatewayRouteTableID: rtID, TransitGatewayAttachmentID: attID, State: state}, + }) +} + +func writeTGWErr(w http.ResponseWriter, err error) { + writeErrWithNotFound(w, err, "InvalidTransitGatewayID.NotFound", "IncorrectState") +} diff --git a/server/aws/ec2/vpc_block_public_access.go b/server/aws/ec2/vpc_block_public_access.go new file mode 100644 index 00000000..f2e06b98 --- /dev/null +++ b/server/aws/ec2/vpc_block_public_access.go @@ -0,0 +1,210 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) vpcBlockPublicAccess() (netdriver.VPCBlockPublicAccess, bool) { + v, ok := h.vpc.(netdriver.VPCBlockPublicAccess) + + return v, ok +} + +func (h *Handler) routeVPCBlockPublicAccess(w http.ResponseWriter, r *http.Request, action string) bool { + v, ok := h.vpcBlockPublicAccess() + if !ok { + return false + } + + switch action { + case "DescribeVpcBlockPublicAccessOptions": + h.describeVPCBPAOptions(w, r, v) + case "ModifyVpcBlockPublicAccessOptions": + h.modifyVPCBPAOptions(w, r, v) + case "CreateVpcBlockPublicAccessExclusion": + h.createVPCBPAExclusion(w, r, v) + case "ModifyVpcBlockPublicAccessExclusion": + h.modifyVPCBPAExclusion(w, r, v) + case "DeleteVpcBlockPublicAccessExclusion": + h.deleteVPCBPAExclusion(w, r, v) + case "DescribeVpcBlockPublicAccessExclusions": + h.describeVPCBPAExclusions(w, r, v) + default: + return false + } + + return true +} + +// ---- XML shapes ---- + +type vpcBPAOptionsXML struct { + AwsAccountID string `xml:"awsAccountId,omitempty"` + AwsRegion string `xml:"awsRegion,omitempty"` + State string `xml:"state,omitempty"` + InternetGatewayBlockMode string `xml:"internetGatewayBlockMode,omitempty"` + ExclusionsAllowed string `xml:"exclusionsAllowed,omitempty"` + ManagedBy string `xml:"managedBy,omitempty"` + Reason string `xml:"reason,omitempty"` + LastUpdateTimestamp string `xml:"lastUpdateTimestamp,omitempty"` +} + +type vpcBPAExclusionXML struct { + ExclusionID string `xml:"exclusionId"` + InternetGatewayExclusionMode string `xml:"internetGatewayExclusionMode,omitempty"` + ResourceArn string `xml:"resourceArn,omitempty"` + State string `xml:"state,omitempty"` + Reason string `xml:"reason,omitempty"` + CreationTimestamp string `xml:"creationTimestamp,omitempty"` + LastUpdateTimestamp string `xml:"lastUpdateTimestamp,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +// ---- options handlers ---- + +func (*Handler) describeVPCBPAOptions(w http.ResponseWriter, r *http.Request, v netdriver.VPCBlockPublicAccess) { + out, err := v.DescribeVPCBlockPublicAccessOptions(r.Context()) + if err != nil { + writeVPCBPAErr(w, err, "InvalidVpcBlockPublicAccessOptions.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeVpcBlockPublicAccessOptionsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Options vpcBPAOptionsXML `xml:"vpcBlockPublicAccessOptions"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Options: toVPCBPAOptionsXML(out)}) +} + +func (*Handler) modifyVPCBPAOptions(w http.ResponseWriter, r *http.Request, v netdriver.VPCBlockPublicAccess) { + out, err := v.ModifyVPCBlockPublicAccessOptions(r.Context(), r.Form.Get("InternetGatewayBlockMode")) + if err != nil { + writeVPCBPAErr(w, err, "InvalidVpcBlockPublicAccessOptions.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"ModifyVpcBlockPublicAccessOptionsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Options vpcBPAOptionsXML `xml:"vpcBlockPublicAccessOptions"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Options: toVPCBPAOptionsXML(out)}) +} + +// ---- exclusion handlers ---- + +func (*Handler) createVPCBPAExclusion(w http.ResponseWriter, r *http.Request, v netdriver.VPCBlockPublicAccess) { + out, err := v.CreateVPCBlockPublicAccessExclusion(r.Context(), netdriver.VPCBlockPublicAccessExclusionConfig{ + VPCID: r.Form.Get("VpcId"), + SubnetID: r.Form.Get("SubnetId"), + InternetGatewayExclusionMode: r.Form.Get("InternetGatewayExclusionMode"), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "vpc-block-public-access-exclusion"), + }) + if err != nil { + // A missing referenced resource keys on the VPC/subnet code, matching EC2. + notFound := "InvalidVpcID.NotFound" + if r.Form.Get("SubnetId") != "" { + notFound = "InvalidSubnetID.NotFound" + } + + writeVPCBPAErr(w, err, notFound) + + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateVpcBlockPublicAccessExclusionResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Exclusion vpcBPAExclusionXML `xml:"vpcBlockPublicAccessExclusion"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Exclusion: toVPCBPAExclusionXML(out)}) +} + +func (*Handler) modifyVPCBPAExclusion(w http.ResponseWriter, r *http.Request, v netdriver.VPCBlockPublicAccess) { + out, err := v.ModifyVPCBlockPublicAccessExclusion(r.Context(), + r.Form.Get("ExclusionId"), r.Form.Get("InternetGatewayExclusionMode")) + if err != nil { + writeVPCBPAErr(w, err, "InvalidVpcBlockPublicAccessExclusionId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"ModifyVpcBlockPublicAccessExclusionResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Exclusion vpcBPAExclusionXML `xml:"vpcBlockPublicAccessExclusion"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Exclusion: toVPCBPAExclusionXML(out)}) +} + +func (*Handler) deleteVPCBPAExclusion(w http.ResponseWriter, r *http.Request, v netdriver.VPCBlockPublicAccess) { + out, err := v.DeleteVPCBlockPublicAccessExclusion(r.Context(), r.Form.Get("ExclusionId")) + if err != nil { + writeVPCBPAErr(w, err, "InvalidVpcBlockPublicAccessExclusionId.NotFound") + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DeleteVpcBlockPublicAccessExclusionResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Exclusion vpcBPAExclusionXML `xml:"vpcBlockPublicAccessExclusion"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Exclusion: toVPCBPAExclusionXML(out)}) +} + +//nolint:dupl // parallel per-resource wire dispatch/marshaling +func (*Handler) describeVPCBPAExclusions(w http.ResponseWriter, r *http.Request, v netdriver.VPCBlockPublicAccess) { + items, err := v.DescribeVPCBlockPublicAccessExclusions(r.Context(), awsquery.ListStrings(r.Form, "ExclusionId")) + if err != nil { + writeVPCBPAErr(w, err, "InvalidVpcBlockPublicAccessExclusionId.NotFound") + return + } + + out := make([]vpcBPAExclusionXML, 0, len(items)) + for i := range items { + out = append(out, toVPCBPAExclusionXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeVpcBlockPublicAccessExclusionsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []vpcBPAExclusionXML `xml:"vpcBlockPublicAccessExclusionSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +// ---- driver → XML ---- + +func toVPCBPAOptionsXML(o *netdriver.VPCBlockPublicAccessOptions) vpcBPAOptionsXML { + return vpcBPAOptionsXML{ + AwsAccountID: o.AWSAccountID, + AwsRegion: o.AWSRegion, + State: o.State, + InternetGatewayBlockMode: o.InternetGatewayBlockMode, + ExclusionsAllowed: o.ExclusionsAllowed, + ManagedBy: o.ManagedBy, + Reason: o.Reason, + LastUpdateTimestamp: formatTime(o.LastUpdateTimestamp), + } +} + +func toVPCBPAExclusionXML(e *netdriver.VPCBlockPublicAccessExclusion) vpcBPAExclusionXML { + return vpcBPAExclusionXML{ + ExclusionID: e.ExclusionID, + InternetGatewayExclusionMode: e.InternetGatewayExclusionMode, + ResourceArn: e.ResourceARN, + State: e.State, + Reason: e.Reason, + CreationTimestamp: formatTime(e.CreationTimestamp), + LastUpdateTimestamp: formatTime(e.LastUpdateTimestamp), + Tags: toTagItems(e.Tags), + } +} + +func writeVPCBPAErr(w http.ResponseWriter, err error, notFoundCode string) { + writeErrWithNotFound(w, err, notFoundCode, "DependencyViolation") +} diff --git a/server/aws/ec2/vpn.go b/server/aws/ec2/vpn.go new file mode 100644 index 00000000..94d2ab78 --- /dev/null +++ b/server/aws/ec2/vpn.go @@ -0,0 +1,386 @@ +package ec2 + +import ( + "encoding/xml" + "net/http" + "strconv" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +func (h *Handler) vpnConnections() (netdriver.VPNConnections, bool) { + v, ok := h.vpc.(netdriver.VPNConnections) + + return v, ok +} + +type customerGatewayXML struct { + CustomerGatewayID string `xml:"customerGatewayId"` + IPAddress string `xml:"ipAddress"` + BgpAsn string `xml:"bgpAsn"` + Type string `xml:"type"` + State string `xml:"state"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type vpnGatewayXML struct { + VpnGatewayID string `xml:"vpnGatewayId"` + Type string `xml:"type"` + State string `xml:"state"` + AmazonSideAsn int64 `xml:"amazonSideAsn,omitempty"` + Attachments []vpnAttachmentXML `xml:"attachments>item,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type vpnAttachmentXML struct { + VpcID string `xml:"vpcId"` + State string `xml:"state"` +} + +type vpnConnectionXML struct { + VpnConnectionID string `xml:"vpnConnectionId"` + CustomerGatewayID string `xml:"customerGatewayId"` + CustomerGatewayConfiguration string `xml:"customerGatewayConfiguration"` + VpnGatewayID string `xml:"vpnGatewayId,omitempty"` + TransitGatewayID string `xml:"transitGatewayId,omitempty"` + Type string `xml:"type"` + State string `xml:"state"` + Routes []vpnRouteXML `xml:"routes>item,omitempty"` + Tags []tagItem `xml:"tagSet>item,omitempty"` +} + +type vpnRouteXML struct { + DestinationCidrBlock string `xml:"destinationCidrBlock"` + State string `xml:"state"` +} + +//nolint:gocyclo // flat action dispatch table +func (h *Handler) routeVPN(w http.ResponseWriter, r *http.Request, action string) bool { + v, ok := h.vpnConnections() + if !ok { + return false + } + + switch action { + case "CreateCustomerGateway": + h.createCustomerGateway(w, r, v) + case "DeleteCustomerGateway": + h.deleteCustomerGateway(w, r, v) + case "DescribeCustomerGateways": + h.describeCustomerGateways(w, r, v) + case "CreateVpnGateway": + h.createVPNGateway(w, r, v) + case "DeleteVpnGateway": + h.deleteVPNGateway(w, r, v) + case "DescribeVpnGateways": + h.describeVPNGateways(w, r, v) + case "AttachVpnGateway": + h.attachVPNGateway(w, r, v) + case "DetachVpnGateway": + h.detachVPNGateway(w, r, v) + case "CreateVpnConnection": + h.createVPNConnection(w, r, v) + case "DeleteVpnConnection": + h.deleteVPNConnection(w, r, v) + case "DescribeVpnConnections": + h.describeVPNConnections(w, r, v) + case "CreateVpnConnectionRoute": + h.createVPNConnectionRoute(w, r, v) + case "DeleteVpnConnectionRoute": + h.deleteVPNConnectionRoute(w, r, v) + case "ModifyVpnConnection": + h.modifyVPNConnection(w, r, v) + default: + return false + } + + return true +} + +func (*Handler) createCustomerGateway(w http.ResponseWriter, r *http.Request, v netdriver.VPNConnections) { + asn, _ := strconv.ParseInt(r.Form.Get("BgpAsn"), 10, 64) + + out, err := v.CreateCustomerGateway(r.Context(), netdriver.CustomerGatewayConfig{ + IPAddress: nonEmpty(r.Form.Get("PublicIp"), r.Form.Get("IpAddress")), + BGPASN: asn, + Type: r.Form.Get("Type"), + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "customer-gateway"), + }) + if err != nil { + writeVPNErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateCustomerGatewayResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + CGW customerGatewayXML `xml:"customerGateway"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, CGW: toCustomerGatewayXML(out)}) +} + +func (*Handler) deleteCustomerGateway(w http.ResponseWriter, r *http.Request, v netdriver.VPNConnections) { + if err := v.DeleteCustomerGateway(r.Context(), r.Form.Get("CustomerGatewayId")); err != nil { + writeVPNErr(w, err) + return + } + + writeReturnTrue(w, "DeleteCustomerGatewayResponse") +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeCustomerGateways(w http.ResponseWriter, r *http.Request, v netdriver.VPNConnections) { + items, err := v.DescribeCustomerGateways(r.Context(), awsquery.ListStrings(r.Form, "CustomerGatewayId")) + if err != nil { + writeVPNErr(w, err) + return + } + + out := make([]customerGatewayXML, 0, len(items)) + for i := range items { + out = append(out, toCustomerGatewayXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeCustomerGatewaysResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []customerGatewayXML `xml:"customerGatewaySet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) createVPNGateway(w http.ResponseWriter, r *http.Request, v netdriver.VPNConnections) { + asn, _ := strconv.ParseInt(r.Form.Get("AmazonSideAsn"), 10, 64) + + out, err := v.CreateVPNGateway(r.Context(), netdriver.VPNGatewayConfig{ + Type: r.Form.Get("Type"), + AmazonSideASN: asn, + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "vpn-gateway"), + }) + if err != nil { + writeVPNErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateVpnGatewayResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + VGW vpnGatewayXML `xml:"vpnGateway"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, VGW: toVPNGatewayXML(out)}) +} + +func (*Handler) deleteVPNGateway(w http.ResponseWriter, r *http.Request, v netdriver.VPNConnections) { + if err := v.DeleteVPNGateway(r.Context(), r.Form.Get("VpnGatewayId")); err != nil { + writeVPNErr(w, err) + return + } + + writeReturnTrue(w, "DeleteVpnGatewayResponse") +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeVPNGateways(w http.ResponseWriter, r *http.Request, v netdriver.VPNConnections) { + items, err := v.DescribeVPNGateways(r.Context(), awsquery.ListStrings(r.Form, "VpnGatewayId")) + if err != nil { + writeVPNErr(w, err) + return + } + + out := make([]vpnGatewayXML, 0, len(items)) + for i := range items { + out = append(out, toVPNGatewayXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeVpnGatewaysResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []vpnGatewayXML `xml:"vpnGatewaySet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) attachVPNGateway(w http.ResponseWriter, r *http.Request, v netdriver.VPNConnections) { + out, err := v.AttachVPNGateway(r.Context(), r.Form.Get("VpnGatewayId"), r.Form.Get("VpcId")) + if err != nil { + writeVPNErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"AttachVpnGatewayResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Attachment vpnAttachmentXML `xml:"attachment"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Attachment: vpnAttachmentXML{VpcID: out.AttachedVPCID, State: out.AttachmentState}}) +} + +func (*Handler) detachVPNGateway(w http.ResponseWriter, r *http.Request, v netdriver.VPNConnections) { + if err := v.DetachVPNGateway(r.Context(), r.Form.Get("VpnGatewayId"), r.Form.Get("VpcId")); err != nil { + writeVPNErr(w, err) + return + } + + writeReturnTrue(w, "DetachVpnGatewayResponse") +} + +func (*Handler) createVPNConnection(w http.ResponseWriter, r *http.Request, v netdriver.VPNConnections) { + out, err := v.CreateVPNConnection(r.Context(), netdriver.VPNConnectionConfig{ + CustomerGatewayID: r.Form.Get("CustomerGatewayId"), + VPNGatewayID: r.Form.Get("VpnGatewayId"), + TransitGatewayID: r.Form.Get("TransitGatewayId"), + Type: r.Form.Get("Type"), + StaticRoutesOnly: r.Form.Get("Options.StaticRoutesOnly") == formTrue, + Tags: mergeTagSpecs(awsquery.TagSpecs(r.Form), "vpn-connection"), + }) + if err != nil { + writeVPNErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"CreateVpnConnectionResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + VPN vpnConnectionXML `xml:"vpnConnection"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, VPN: toVPNConnectionXML(out)}) +} + +func (*Handler) deleteVPNConnection(w http.ResponseWriter, r *http.Request, v netdriver.VPNConnections) { + if err := v.DeleteVPNConnection(r.Context(), r.Form.Get("VpnConnectionId")); err != nil { + writeVPNErr(w, err) + return + } + + writeReturnTrue(w, "DeleteVpnConnectionResponse") +} + +//nolint:dupl // parallel per-resource marshaling +func (*Handler) describeVPNConnections(w http.ResponseWriter, r *http.Request, v netdriver.VPNConnections) { + items, err := v.DescribeVPNConnections(r.Context(), awsquery.ListStrings(r.Form, "VpnConnectionId")) + if err != nil { + writeVPNErr(w, err) + return + } + + out := make([]vpnConnectionXML, 0, len(items)) + for i := range items { + out = append(out, toVPNConnectionXML(&items[i])) + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"DescribeVpnConnectionsResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + Set []vpnConnectionXML `xml:"vpnConnectionSet>item"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, Set: out}) +} + +func (*Handler) createVPNConnectionRoute(w http.ResponseWriter, r *http.Request, v netdriver.VPNConnections) { + err := v.CreateVPNConnectionRoute(r.Context(), r.Form.Get("VpnConnectionId"), r.Form.Get("DestinationCidrBlock")) + if err != nil { + writeVPNErr(w, err) + return + } + + writeReturnTrue(w, "CreateVpnConnectionRouteResponse") +} + +func (*Handler) deleteVPNConnectionRoute(w http.ResponseWriter, r *http.Request, v netdriver.VPNConnections) { + err := v.DeleteVPNConnectionRoute(r.Context(), r.Form.Get("VpnConnectionId"), r.Form.Get("DestinationCidrBlock")) + if err != nil { + writeVPNErr(w, err) + return + } + + writeReturnTrue(w, "DeleteVpnConnectionRouteResponse") +} + +func (*Handler) modifyVPNConnection(w http.ResponseWriter, r *http.Request, v netdriver.VPNConnections) { + out, err := v.ModifyVPNConnection(r.Context(), + r.Form.Get("VpnConnectionId"), r.Form.Get("TransitGatewayId"), r.Form.Get("VpnGatewayId")) + if err != nil { + writeVPNErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, struct { + XMLName xml.Name `xml:"ModifyVpnConnectionResponse"` + Xmlns string `xml:"xmlns,attr"` + Req string `xml:"requestId"` + VPN vpnConnectionXML `xml:"vpnConnection"` + }{Xmlns: awsquery.Namespace, Req: awsquery.RequestID, VPN: toVPNConnectionXML(out)}) +} + +func toCustomerGatewayXML(c *netdriver.CustomerGateway) customerGatewayXML { + return customerGatewayXML{ + CustomerGatewayID: c.ID, IPAddress: c.IPAddress, BgpAsn: strconv.FormatInt(c.BGPASN, 10), + Type: c.Type, State: c.State, Tags: toTagItems(c.Tags), + } +} + +func toVPNGatewayXML(v *netdriver.VPNGateway) vpnGatewayXML { + x := vpnGatewayXML{ + VpnGatewayID: v.ID, Type: v.Type, State: v.State, AmazonSideAsn: v.AmazonSideASN, + Tags: toTagItems(v.Tags), + } + if v.AttachedVPCID != "" { + x.Attachments = []vpnAttachmentXML{{VpcID: v.AttachedVPCID, State: v.AttachmentState}} + } + + return x +} + +func toVPNConnectionXML(v *netdriver.VPNConnection) vpnConnectionXML { + x := vpnConnectionXML{ + VpnConnectionID: v.ID, CustomerGatewayID: v.CustomerGatewayID, VpnGatewayID: v.VPNGatewayID, + TransitGatewayID: v.TransitGatewayID, Type: v.Type, State: v.State, + CustomerGatewayConfiguration: customerGatewayConfiguration(v), + Tags: toTagItems(v.Tags), + } + + for _, rt := range v.Routes { + x.Routes = append(x.Routes, vpnRouteXML{DestinationCidrBlock: rt.DestinationCIDR, State: rt.State}) + } + + return x +} + +// customerGatewayConfiguration returns the IPsec tunnel-config document that +// real Site-to-Site VPN responses always carry (Terraform's aws_vpn_connection +// parses it). The emulator has no real tunnels, so this is a minimal but +// structurally-valid stub identifying the connection and its gateways. +func customerGatewayConfiguration(v *netdriver.VPNConnection) string { + var b strings.Builder + + b.WriteString(``) + b.WriteString(``) + b.WriteString(`` + v.CustomerGatewayID + ``) + + if v.VPNGatewayID != "" { + b.WriteString(`` + v.VPNGatewayID + ``) + } + + if v.TransitGatewayID != "" { + b.WriteString(`` + v.TransitGatewayID + ``) + } + + b.WriteString(`` + orDefault(v.Type, "ipsec.1") + ``) + b.WriteString(``) + + return b.String() +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + + return v +} + +func writeVPNErr(w http.ResponseWriter, err error) { + writeErrWithNotFound(w, err, "InvalidVpnGatewayID.NotFound", "IncorrectState") +} diff --git a/server/aws/ec2/xml.go b/server/aws/ec2/xml.go index 900875b6..76e9c3c7 100644 --- a/server/aws/ec2/xml.go +++ b/server/aws/ec2/xml.go @@ -15,6 +15,9 @@ const ( // Canonical "owner" returned in responses. SDK clients don't validate it; // any 12-digit account id works. ownerID = "123456789012" + + // stateRunning is the driver's string name for a running instance. + stateRunning = "running" ) // stateCode maps the driver's string state to AWS's numeric code. @@ -22,7 +25,7 @@ func stateCode(name string) int { switch name { case "pending": return stateCodePending - case "running": + case stateRunning: return stateCodeRunning case "shutting-down": return stateCodeShuttingDown diff --git a/server/aws/ec2_test.go b/server/aws/ec2_test.go index 335662cf..d24be012 100644 --- a/server/aws/ec2_test.go +++ b/server/aws/ec2_test.go @@ -713,16 +713,20 @@ func TestEC2DescribeTerminatedInstanceStillVisible(t *testing.T) { "terminated instance should still be described") } -func TestEC2DescribeInstancesByUnknownIDReturnsEmpty(t *testing.T) { - // Real AWS returns an error; our provider returns empty. Document behavior. +func TestEC2DescribeInstancesByUnknownIDReturnsNotFound(t *testing.T) { + // Real AWS returns InvalidInstanceID.NotFound for an explicit missing ID + // (issue #319, theme C); a prior version returned an empty success. client := newEC2Client(t) ctx := context.Background() - out, err := client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ + _, err := client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ InstanceIds: []string{"i-deadbeef"}, }) - require.NoError(t, err) - assert.Empty(t, collectIDs(out)) + require.Error(t, err) + + var apiErr smithy.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, "InvalidInstanceID.NotFound", apiErr.ErrorCode()) } func TestEC2StopIdempotent(t *testing.T) { diff --git a/server/aws/ecr/handler.go b/server/aws/ecr/handler.go index cdbded03..62923b1a 100644 --- a/server/aws/ecr/handler.go +++ b/server/aws/ecr/handler.go @@ -8,8 +8,10 @@ package ecr import ( + "context" "net/http" "strings" + "time" cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/server/wire" @@ -18,6 +20,14 @@ import ( const targetPrefix = "AmazonEC2ContainerRegistry_V20150921." +// authTokenProvider is the AWS-specific GetAuthorizationToken surface. ECR +// registry auth is not part of the portable ContainerRegistry driver (Azure +// ACR and GCP Artifact Registry authenticate differently), so the handler +// type-asserts for it rather than widening the shared interface. +type authTokenProvider interface { + GetAuthorizationToken(ctx context.Context) (token, proxyEndpoint string, expiresAt time.Time, err error) +} + // Handler serves ECR JSON-RPC requests against a ContainerRegistry driver. type Handler struct { registry crdriver.ContainerRegistry @@ -51,6 +61,20 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.describeImages(w, r) case "BatchDeleteImage": h.batchDeleteImage(w, r) + case "GetAuthorizationToken": + h.getAuthorizationToken(w, r) + case "TagResource": + h.tagResource(w, r) + case "UntagResource": + h.untagResource(w, r) + case "ListTagsForResource": + h.listTagsForResource(w, r) + case "SetRepositoryPolicy": + h.setRepositoryPolicy(w, r) + case "GetRepositoryPolicy": + h.getRepositoryPolicy(w, r) + case "DeleteRepositoryPolicy": + h.deleteRepositoryPolicy(w, r) default: op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) wire.WriteJSONError(w, http.StatusBadRequest, @@ -58,6 +82,32 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +// getAuthorizationToken returns a docker-login credential. The response shape +// matches the AWS SDK's AuthorizationData: a base64 token, an expiry, and the +// registry proxy endpoint. +func (h *Handler) getAuthorizationToken(w http.ResponseWriter, r *http.Request) { + auth, ok := h.registry.(authTokenProvider) + if !ok { + wire.WriteJSONError(w, http.StatusBadRequest, + "ServerException", "authorization token not supported") + return + } + + token, endpoint, expiresAt, err := auth.GetAuthorizationToken(r.Context()) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{ + "authorizationData": []map[string]any{{ + "authorizationToken": token, + "proxyEndpoint": endpoint, + "expiresAt": expiresAt.Unix(), + }}, + }) +} + // writeErr maps canonical cloudemu errors to ECR JSON error responses. ECR // returns errors as HTTP 400 with a "__type" body the SDK maps to a typed // exception. diff --git a/server/aws/ecr/repopolicy.go b/server/aws/ecr/repopolicy.go new file mode 100644 index 00000000..3eee304c --- /dev/null +++ b/server/aws/ecr/repopolicy.go @@ -0,0 +1,96 @@ +package ecr + +import ( + "context" + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// repoPolicyManager is the AWS-specific ECR repository-policy surface, asserted +// against the provider (not part of the portable ContainerRegistry driver). +type repoPolicyManager interface { + SetRepositoryPolicy(ctx context.Context, repository, policyText string) (string, error) + GetRepositoryPolicy(ctx context.Context, repository string) (string, error) + DeleteRepositoryPolicy(ctx context.Context, repository string) (string, error) +} + +func (h *Handler) repoPolicyMgr() (repoPolicyManager, bool) { + m, ok := h.registry.(repoPolicyManager) + + return m, ok +} + +func (h *Handler) setRepositoryPolicy(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.repoPolicyMgr() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "repository policies not supported")) + return + } + + var req struct { + RepositoryName string `json:"repositoryName"` + PolicyText string `json:"policyText"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + policy, err := mgr.SetRepositoryPolicy(r.Context(), req.RepositoryName, req.PolicyText) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{"repositoryName": req.RepositoryName, "policyText": policy}) +} + +func (h *Handler) getRepositoryPolicy(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.repoPolicyMgr() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "repository policies not supported")) + return + } + + var req struct { + RepositoryName string `json:"repositoryName"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + policy, err := mgr.GetRepositoryPolicy(r.Context(), req.RepositoryName) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{"repositoryName": req.RepositoryName, "policyText": policy}) +} + +func (h *Handler) deleteRepositoryPolicy(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.repoPolicyMgr() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "repository policies not supported")) + return + } + + var req struct { + RepositoryName string `json:"repositoryName"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + policy, err := mgr.DeleteRepositoryPolicy(r.Context(), req.RepositoryName) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{"repositoryName": req.RepositoryName, "policyText": policy}) +} diff --git a/server/aws/ecr/sdk_roundtrip_test.go b/server/aws/ecr/sdk_roundtrip_test.go index dfa625f4..cf96fc40 100644 --- a/server/aws/ecr/sdk_roundtrip_test.go +++ b/server/aws/ecr/sdk_roundtrip_test.go @@ -2,9 +2,12 @@ package ecr_test import ( "context" + "encoding/base64" "errors" "net/http/httptest" + "strings" "testing" + "time" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" @@ -40,6 +43,49 @@ func newECRClient(t *testing.T) *awsecr.Client { }) } +// TestSDKECRRepositoryPolicy is a regression guard for the #320 review +// follow-up: Set/Get/DeleteRepositoryPolicy round-trip a resource policy. +func TestSDKECRRepositoryPolicy(t *testing.T) { + client := newECRClient(t) + ctx := context.Background() + + if _, err := client.CreateRepository(ctx, &awsecr.CreateRepositoryInput{ + RepositoryName: aws.String("policy-repo"), + }); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + + const policy = `{"Version":"2008-10-17","Statement":[{"Sid":"a","Effect":"Allow","Principal":"*","Action":"ecr:GetDownloadUrlForLayer"}]}` + + set, err := client.SetRepositoryPolicy(ctx, &awsecr.SetRepositoryPolicyInput{ + RepositoryName: aws.String("policy-repo"), PolicyText: aws.String(policy), + }) + if err != nil { + t.Fatalf("SetRepositoryPolicy: %v", err) + } + + if aws.ToString(set.PolicyText) != policy { + t.Fatalf("SetRepositoryPolicy echoed %q", aws.ToString(set.PolicyText)) + } + + got, err := client.GetRepositoryPolicy(ctx, &awsecr.GetRepositoryPolicyInput{ + RepositoryName: aws.String("policy-repo"), + }) + if err != nil { + t.Fatalf("GetRepositoryPolicy: %v", err) + } + + if aws.ToString(got.PolicyText) != policy { + t.Fatalf("GetRepositoryPolicy = %q", aws.ToString(got.PolicyText)) + } + + if _, err := client.DeleteRepositoryPolicy(ctx, &awsecr.DeleteRepositoryPolicyInput{ + RepositoryName: aws.String("policy-repo"), + }); err != nil { + t.Fatalf("DeleteRepositoryPolicy: %v", err) + } +} + func TestSDKECRRepositoryLifecycle(t *testing.T) { client := newECRClient(t) ctx := context.Background() @@ -92,6 +138,42 @@ func TestSDKECRRepositoryLifecycle(t *testing.T) { } } +// TestSDKECRGetAuthorizationToken is a regression guard for issue #319: +// GetAuthorizationToken (required for `docker login` / image push+pull) was +// unimplemented. The SDK must decode a base64 "AWS:" token, a proxy +// endpoint, and an expiry. +func TestSDKECRGetAuthorizationToken(t *testing.T) { + client := newECRClient(t) + + out, err := client.GetAuthorizationToken(context.Background(), &awsecr.GetAuthorizationTokenInput{}) + if err != nil { + t.Fatalf("GetAuthorizationToken: %v", err) + } + + if len(out.AuthorizationData) != 1 { + t.Fatalf("got %d authorization entries, want 1", len(out.AuthorizationData)) + } + + data := out.AuthorizationData[0] + + decoded, err := base64.StdEncoding.DecodeString(aws.ToString(data.AuthorizationToken)) + if err != nil { + t.Fatalf("token not base64: %v", err) + } + + if !strings.HasPrefix(string(decoded), "AWS:") { + t.Fatalf("decoded token = %q, want AWS:", string(decoded)) + } + + if !strings.Contains(aws.ToString(data.ProxyEndpoint), ".dkr.ecr.") { + t.Fatalf("proxy endpoint = %q", aws.ToString(data.ProxyEndpoint)) + } + + if data.ExpiresAt == nil || !data.ExpiresAt.After(time.Now()) { + t.Fatalf("expiresAt = %v, want a future time", data.ExpiresAt) + } +} + func TestSDKECRImageLifecycle(t *testing.T) { client := newECRClient(t) ctx := context.Background() diff --git a/server/aws/ecr/tags.go b/server/aws/ecr/tags.go new file mode 100644 index 00000000..fcb91216 --- /dev/null +++ b/server/aws/ecr/tags.go @@ -0,0 +1,124 @@ +package ecr + +import ( + "context" + "net/http" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// repositoryTagger is the AWS-specific ECR tagging surface, asserted against +// the provider (not part of the portable ContainerRegistry driver). +type repositoryTagger interface { + TagRepository(ctx context.Context, name string, tags map[string]string) error + UntagRepository(ctx context.Context, name string, keys []string) error + ListRepositoryTags(ctx context.Context, name string) (map[string]string, error) +} + +type ecrTag struct { + Key string `json:"Key"` + Value string `json:"Value"` +} + +// repoFromARN resolves an ECR ResourceArn +// ("arn:aws:ecr:::repository/") to the bare repository +// name. A non-ARN value is returned unchanged. +func repoFromARN(arn string) string { + const marker = ":repository/" + + if i := strings.LastIndex(arn, marker); i >= 0 { + return arn[i+len(marker):] + } + + return arn +} + +func (h *Handler) repoTagger() (repositoryTagger, bool) { + t, ok := h.registry.(repositoryTagger) + + return t, ok +} + +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.repoTagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceArn string `json:"resourceArn"` + Tags []ecrTag `json:"tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags := make(map[string]string, len(req.Tags)) + for _, t := range req.Tags { + tags[t.Key] = t.Value + } + + if err := tagger.TagRepository(r.Context(), repoFromARN(req.ResourceArn), tags); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.repoTagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceArn string `json:"resourceArn"` + TagKeys []string `json:"tagKeys"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := tagger.UntagRepository(r.Context(), repoFromARN(req.ResourceArn), req.TagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) listTagsForResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.repoTagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceArn string `json:"resourceArn"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags, err := tagger.ListRepositoryTags(r.Context(), repoFromARN(req.ResourceArn)) + if err != nil { + writeErr(w, err) + return + } + + out := make([]ecrTag, 0, len(tags)) + for k, v := range tags { + out = append(out, ecrTag{Key: k, Value: v}) + } + + wire.WriteJSON(w, map[string]any{"tags": out}) +} diff --git a/server/aws/eks/handler.go b/server/aws/eks/handler.go index a9189105..a7f6d388 100644 --- a/server/aws/eks/handler.go +++ b/server/aws/eks/handler.go @@ -31,6 +31,9 @@ const ( pathPrefix = "/clusters" + // tagsPrefix is the EKS tagging API root: /tags/{resourceArn}. + tagsPrefix = "/tags/" + // segNodeGroups, segFargateProfiles, segAddons are the EKS sub-resource // path segments. Real SDK kebab-cases them (note "node-groups" with a // hyphen; the JSON body field is camelCase "nodegroupName"). @@ -71,11 +74,17 @@ func (*Handler) Matches(r *http.Request) bool { return true } - return strings.HasPrefix(r.URL.Path, pathPrefix+"/") + return strings.HasPrefix(r.URL.Path, pathPrefix+"/") || + strings.HasPrefix(r.URL.Path, tagsPrefix) } // ServeHTTP routes EKS requests by URL shape. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, tagsPrefix) { + h.serveTags(w, r, strings.TrimPrefix(r.URL.Path, tagsPrefix)) + return + } + parts := splitPath(r.URL.Path) switch len(parts) { diff --git a/server/aws/eks/tags.go b/server/aws/eks/tags.go new file mode 100644 index 00000000..fdd2d216 --- /dev/null +++ b/server/aws/eks/tags.go @@ -0,0 +1,59 @@ +package eks + +import ( + "context" + "net/http" +) + +// clusterTagger is the AWS-specific EKS tagging surface, asserted against the +// provider (not part of the portable EKS driver). +type clusterTagger interface { + TagResource(ctx context.Context, arn string, tags map[string]string) error + UntagResource(ctx context.Context, arn string, keys []string) error + ListResourceTags(ctx context.Context, arn string) (map[string]string, error) +} + +// serveTags handles the EKS tagging API at /tags/{resourceArn}: +// POST=TagResource, DELETE=UntagResource (?tagKeys=...), GET=ListTagsForResource. +func (h *Handler) serveTags(w http.ResponseWriter, r *http.Request, arn string) { + tagger, ok := h.eks.(clusterTagger) + if !ok { + writeError(w, http.StatusNotImplemented, "InvalidRequestException", "tagging not supported") + return + } + + switch r.Method { + case http.MethodPost: + var req struct { + Tags map[string]string `json:"tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + if err := tagger.TagResource(r.Context(), arn, req.Tags); err != nil { + writeErr(w, err) + return + } + + writeJSON(w, struct{}{}) + case http.MethodDelete: + if err := tagger.UntagResource(r.Context(), arn, r.URL.Query()["tagKeys"]); err != nil { + writeErr(w, err) + return + } + + writeJSON(w, struct{}{}) + case http.MethodGet: + tags, err := tagger.ListResourceTags(r.Context(), arn) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, map[string]any{"tags": tags}) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} diff --git a/server/aws/elasticache/handler.go b/server/aws/elasticache/handler.go index e711e2cd..119f4b32 100644 --- a/server/aws/elasticache/handler.go +++ b/server/aws/elasticache/handler.go @@ -44,6 +44,7 @@ var elastiCacheActions = map[string]struct{}{ //nolint:gochecknoglobals // stati "DeleteCacheSubnetGroup": {}, "CreateCacheCluster": {}, "DescribeCacheClusters": {}, + "ModifyCacheCluster": {}, "DeleteCacheCluster": {}, "CreateReplicationGroup": {}, "DescribeReplicationGroups": {}, @@ -99,6 +100,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.deleteCacheSubnetGroup(w, r) case "CreateCacheCluster": h.createCacheCluster(w, r) + case "ModifyCacheCluster": + h.modifyCacheCluster(w, r) case "DescribeCacheClusters": h.describeCacheClusters(w, r) case "CreateReplicationGroup": diff --git a/server/aws/elasticache/operations.go b/server/aws/elasticache/operations.go index 27dc3274..8d7e4bac 100644 --- a/server/aws/elasticache/operations.go +++ b/server/aws/elasticache/operations.go @@ -1,10 +1,12 @@ package elasticache import ( + "context" "net/http" "net/url" "strconv" + cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" cachedriver "github.com/stackshy/cloudemu/v2/services/cache/driver" "github.com/stackshy/cloudemu/v2/services/scope" @@ -52,6 +54,34 @@ func (h *Handler) createCacheCluster(w http.ResponseWriter, r *http.Request) { }) } +// cacheModifier is the AWS-specific ModifyCacheCluster surface. It's not part +// of the portable Cache driver (Azure Cache and GCP Memorystore also implement +// it), so the handler type-asserts for it. +type cacheModifier interface { + ModifyCache(ctx context.Context, name, nodeType, engine string) (*cachedriver.CacheInfo, error) +} + +func (h *Handler) modifyCacheCluster(w http.ResponseWriter, r *http.Request) { + mod, ok := h.cache.(cacheModifier) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "ModifyCacheCluster not supported")) + return + } + + info, err := mod.ModifyCache(r.Context(), + r.Form.Get("CacheClusterId"), r.Form.Get("CacheNodeType"), r.Form.Get("Engine")) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, modifyCacheClusterResponse{ + Xmlns: Namespace, + Result: cacheClusterResult{CacheCluster: toCacheClusterXML(info)}, + Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + func (h *Handler) describeCacheClusters(w http.ResponseWriter, r *http.Request) { id := r.Form.Get("CacheClusterId") diff --git a/server/aws/elasticache/sdk_roundtrip_test.go b/server/aws/elasticache/sdk_roundtrip_test.go index 1179b29e..e0ed93f2 100644 --- a/server/aws/elasticache/sdk_roundtrip_test.go +++ b/server/aws/elasticache/sdk_roundtrip_test.go @@ -47,6 +47,42 @@ func newSDKClient(t *testing.T) *awselasticache.Client { }) } +// TestSDKModifyCacheCluster is a regression guard for issue #319: +// ModifyCacheCluster was unimplemented (InvalidAction). +func TestSDKModifyCacheCluster(t *testing.T) { + client := newSDKClient(t) + ctx := context.Background() + + if _, err := client.CreateCacheCluster(ctx, &awselasticache.CreateCacheClusterInput{ + CacheClusterId: aws.String("mc"), Engine: aws.String("redis"), + CacheNodeType: aws.String("cache.t3.micro"), NumCacheNodes: aws.Int32(1), + }); err != nil { + t.Fatalf("CreateCacheCluster: %v", err) + } + + out, err := client.ModifyCacheCluster(ctx, &awselasticache.ModifyCacheClusterInput{ + CacheClusterId: aws.String("mc"), CacheNodeType: aws.String("cache.t3.medium"), + }) + if err != nil { + t.Fatalf("ModifyCacheCluster: %v", err) + } + + if aws.ToString(out.CacheCluster.CacheNodeType) != "cache.t3.medium" { + t.Fatalf("node type = %q, want cache.t3.medium", aws.ToString(out.CacheCluster.CacheNodeType)) + } + + got, err := client.DescribeCacheClusters(ctx, &awselasticache.DescribeCacheClustersInput{ + CacheClusterId: aws.String("mc"), + }) + if err != nil { + t.Fatalf("DescribeCacheClusters: %v", err) + } + + if aws.ToString(got.CacheClusters[0].CacheNodeType) != "cache.t3.medium" { + t.Fatalf("persisted node type = %q", aws.ToString(got.CacheClusters[0].CacheNodeType)) + } +} + func TestSDKElastiCacheLifecycle(t *testing.T) { client := newSDKClient(t) ctx := context.Background() diff --git a/server/aws/elasticache/types.go b/server/aws/elasticache/types.go index 2b2cc887..054d0929 100644 --- a/server/aws/elasticache/types.go +++ b/server/aws/elasticache/types.go @@ -64,6 +64,13 @@ type createCacheClusterResponse struct { Metadata responseMetadata `xml:"ResponseMetadata"` } +type modifyCacheClusterResponse struct { + XMLName xml.Name `xml:"ModifyCacheClusterResponse"` + Xmlns string `xml:"xmlns,attr"` + Result cacheClusterResult `xml:"ModifyCacheClusterResult"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + type deleteCacheClusterResponse struct { XMLName xml.Name `xml:"DeleteCacheClusterResponse"` Xmlns string `xml:"xmlns,attr"` diff --git a/server/aws/elbv2/attributes_sdk_roundtrip_test.go b/server/aws/elbv2/attributes_sdk_roundtrip_test.go index 874a8e70..aeaf4f7d 100644 --- a/server/aws/elbv2/attributes_sdk_roundtrip_test.go +++ b/server/aws/elbv2/attributes_sdk_roundtrip_test.go @@ -137,6 +137,47 @@ func TestModifyLoadBalancerAttributesMerges(t *testing.T) { // A sweep for orphaned infrastructure identifies its own load balancers by // tag; an empty answer reads as "not mine" and leaves the orphan standing. +// TestAddAndRemoveTags is a regression guard for issue #319: AddTags/RemoveTags +// were unimplemented, so tags could only be set at create time. +func TestAddAndRemoveTags(t *testing.T) { + ctx := context.Background() + c := newELBClient(t) + + arn := mkLB(t, c, "nlb-mut", nil) + + if _, err := c.AddTags(ctx, &awselbv2.AddTagsInput{ + ResourceArns: []string{arn}, + Tags: []elbv2types.Tag{{Key: aws.String("env"), Value: aws.String("prod")}}, + }); err != nil { + t.Fatalf("AddTags: %v", err) + } + + got, err := c.DescribeTags(ctx, &awselbv2.DescribeTagsInput{ResourceArns: []string{arn}}) + if err != nil { + t.Fatalf("DescribeTags: %v", err) + } + + if len(got.TagDescriptions) != 1 || len(got.TagDescriptions[0].Tags) != 1 || + aws.ToString(got.TagDescriptions[0].Tags[0].Key) != "env" { + t.Fatalf("after AddTags: %+v", got.TagDescriptions) + } + + if _, err := c.RemoveTags(ctx, &awselbv2.RemoveTagsInput{ + ResourceArns: []string{arn}, TagKeys: []string{"env"}, + }); err != nil { + t.Fatalf("RemoveTags: %v", err) + } + + got, err = c.DescribeTags(ctx, &awselbv2.DescribeTagsInput{ResourceArns: []string{arn}}) + if err != nil { + t.Fatalf("DescribeTags after remove: %v", err) + } + + if len(got.TagDescriptions) == 1 && len(got.TagDescriptions[0].Tags) != 0 { + t.Fatalf("tags remained after RemoveTags: %+v", got.TagDescriptions[0].Tags) + } +} + func TestDescribeTagsReturnsLoadBalancerTags(t *testing.T) { ctx := context.Background() c := newELBClient(t) diff --git a/server/aws/elbv2/handler.go b/server/aws/elbv2/handler.go index da76e0fe..969011fe 100644 --- a/server/aws/elbv2/handler.go +++ b/server/aws/elbv2/handler.go @@ -37,6 +37,8 @@ var elbActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup "ModifyLoadBalancerAttributes": {}, "DescribeLoadBalancerAttributes": {}, "DescribeTags": {}, + "AddTags": {}, + "RemoveTags": {}, "DescribeLoadBalancers": {}, "DeleteLoadBalancer": {}, "CreateTargetGroup": {}, @@ -107,6 +109,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.describeLoadBalancerAttributes(w, r) case "DescribeTags": h.describeTags(w, r) + case "AddTags": + h.addTags(w, r) + case "RemoveTags": + h.removeTags(w, r) case "DeleteLoadBalancer": h.deleteLoadBalancer(w, r) case "CreateTargetGroup": diff --git a/server/aws/elbv2/tags.go b/server/aws/elbv2/tags.go index 76503cd5..076ea88d 100644 --- a/server/aws/elbv2/tags.go +++ b/server/aws/elbv2/tags.go @@ -1,12 +1,82 @@ package elbv2 import ( + "context" "encoding/xml" "net/http" + cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" ) +// tagMutator is the AWS-specific ELBv2 tag-write surface, asserted against the +// provider (not part of the portable LoadBalancer driver). +type tagMutator interface { + AddResourceTags(ctx context.Context, arn string, tags map[string]string) error + RemoveResourceTags(ctx context.Context, arn string, keys []string) error +} + +// The ELBv2 SDK unmarshaler expects the empty wrapper element. +type addTagsResponse struct { + XMLName xml.Name `xml:"AddTagsResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct{} `xml:"AddTagsResult"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type removeTagsResponse struct { + XMLName xml.Name `xml:"RemoveTagsResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct{} `xml:"RemoveTagsResult"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +func (h *Handler) tagMutator() (tagMutator, bool) { + m, ok := h.lb.(tagMutator) + + return m, ok +} + +func (h *Handler) addTags(w http.ResponseWriter, r *http.Request) { + mut, ok := h.tagMutator() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + tags := awsquery.FlatTags(r.Form, "Tags.member") + for _, arn := range awsquery.ListStrings(r.Form, "ResourceArns.member") { + if err := mut.AddResourceTags(r.Context(), arn, tags); err != nil { + writeErr(w, err) + return + } + } + + awsquery.WriteXMLResponse(w, addTagsResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) removeTags(w http.ResponseWriter, r *http.Request) { + mut, ok := h.tagMutator() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + keys := awsquery.ListStrings(r.Form, "TagKeys.member") + for _, arn := range awsquery.ListStrings(r.Form, "ResourceArns.member") { + if err := mut.RemoveResourceTags(r.Context(), arn, keys); err != nil { + writeErr(w, err) + return + } + } + + awsquery.WriteXMLResponse(w, removeTagsResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + type tagMemberXML struct { Key string `xml:"Key"` Value string `xml:"Value"` diff --git a/server/aws/eventbridge/handler.go b/server/aws/eventbridge/handler.go index dcee2cf9..1f500f60 100644 --- a/server/aws/eventbridge/handler.go +++ b/server/aws/eventbridge/handler.go @@ -22,12 +22,15 @@ const targetPrefix = "AWSEvents." // Handler serves EventBridge JSON-RPC requests against an EventBus driver. type Handler struct { - bus ebdriver.EventBus + bus ebdriver.EventBus + accountID string + region string } -// New returns an EventBridge handler backed by b. -func New(b ebdriver.EventBus) *Handler { - return &Handler{bus: b} +// New returns an EventBridge handler backed by b. accountID and region are used +// to synthesize well-formed rule ARNs. +func New(b ebdriver.EventBus, accountID, region string) *Handler { + return &Handler{bus: b, accountID: accountID, region: region} } // Matches returns true for EventBridge-shaped requests, identified by an @@ -69,6 +72,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.listTargetsByRule(w, r) case "PutEvents": h.putEvents(w, r) + case "TagResource": + h.tagResource(w, r) + case "UntagResource": + h.untagResource(w, r) + case "ListTagsForResource": + h.listTagsForResource(w, r) default: wire.WriteJSONError(w, http.StatusBadRequest, "UnknownOperationException", "unknown EventBridge operation: "+op) diff --git a/server/aws/eventbridge/operations.go b/server/aws/eventbridge/operations.go index b31300dd..32b8e70e 100644 --- a/server/aws/eventbridge/operations.go +++ b/server/aws/eventbridge/operations.go @@ -103,7 +103,7 @@ func (h *Handler) putRule(w http.ResponseWriter, r *http.Request) { return } - wire.WriteJSON(w, putRuleResponse{RuleArn: ruleARN(rule.EventBus, rule.Name)}) + wire.WriteJSON(w, putRuleResponse{RuleArn: h.ruleARN(rule.EventBus, rule.Name)}) } func (h *Handler) describeRule(w http.ResponseWriter, r *http.Request) { @@ -119,7 +119,7 @@ func (h *Handler) describeRule(w http.ResponseWriter, r *http.Request) { } wire.WriteJSON(w, describeRuleResponse{ - Arn: ruleARN(rule.EventBus, rule.Name), + Arn: h.ruleARN(rule.EventBus, rule.Name), Name: rule.Name, EventBusName: rule.EventBus, Description: rule.Description, @@ -143,7 +143,7 @@ func (h *Handler) listRules(w http.ResponseWriter, r *http.Request) { entries := make([]ruleEntry, 0, len(rules)) for i := range rules { entries = append(entries, ruleEntry{ - Arn: ruleARN(rules[i].EventBus, rules[i].Name), + Arn: h.ruleARN(rules[i].EventBus, rules[i].Name), Name: rules[i].Name, EventBusName: rules[i].EventBus, Description: rules[i].Description, diff --git a/server/aws/eventbridge/tags.go b/server/aws/eventbridge/tags.go new file mode 100644 index 00000000..c05c6faa --- /dev/null +++ b/server/aws/eventbridge/tags.go @@ -0,0 +1,110 @@ +package eventbridge + +import ( + "context" + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// resourceTagger is the AWS-specific EventBridge tagging surface, asserted +// against the provider (not part of the portable EventBus driver). +type resourceTagger interface { + TagResource(ctx context.Context, arn string, tags map[string]string) error + UntagResource(ctx context.Context, arn string, keys []string) error + ListResourceTags(ctx context.Context, arn string) (map[string]string, error) +} + +type ebTag struct { + Key string `json:"Key"` + Value string `json:"Value"` +} + +func (h *Handler) tagger() (resourceTagger, bool) { + t, ok := h.bus.(resourceTagger) + + return t, ok +} + +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.tagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceARN string `json:"ResourceARN"` + Tags []ebTag `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags := make(map[string]string, len(req.Tags)) + for _, t := range req.Tags { + tags[t.Key] = t.Value + } + + if err := tagger.TagResource(r.Context(), req.ResourceARN, tags); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.tagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceARN string `json:"ResourceARN"` + TagKeys []string `json:"TagKeys"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := tagger.UntagResource(r.Context(), req.ResourceARN, req.TagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) listTagsForResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.tagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceARN string `json:"ResourceARN"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags, err := tagger.ListResourceTags(r.Context(), req.ResourceARN) + if err != nil { + writeErr(w, err) + return + } + + out := make([]ebTag, 0, len(tags)) + for k, v := range tags { + out = append(out, ebTag{Key: k, Value: v}) + } + + wire.WriteJSON(w, map[string]any{"Tags": out}) +} diff --git a/server/aws/eventbridge/types.go b/server/aws/eventbridge/types.go index a7563dcc..b6661f2b 100644 --- a/server/aws/eventbridge/types.go +++ b/server/aws/eventbridge/types.go @@ -190,12 +190,12 @@ func epochSeconds(iso string) float64 { // EventBridge rule ARNs are "arn:aws:events:::rule//"; // region/account aren't threaded into this handler, so they're left as // placeholders that keep the ARN shape recognizable. -func ruleARN(bus, rule string) string { +func (h *Handler) ruleARN(bus, rule string) string { if bus == "" { bus = defaultBusName } - return "arn:aws:events:::rule/" + bus + "/" + rule + return "arn:aws:events:" + h.region + ":" + h.accountID + ":rule/" + bus + "/" + rule } func toTargetJSON(t *ebdriver.Target) targetJSON { diff --git a/server/aws/iam/handler.go b/server/aws/iam/handler.go index 95d1b66d..259794a3 100644 --- a/server/aws/iam/handler.go +++ b/server/aws/iam/handler.go @@ -11,6 +11,7 @@ package iam import ( + "context" "net/http" "strings" @@ -71,6 +72,30 @@ var iamActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup "ListInstanceProfiles": {}, "AddRoleToInstanceProfile": {}, "RemoveRoleFromInstanceProfile": {}, + "PutRolePolicy": {}, + "GetRolePolicy": {}, + "DeleteRolePolicy": {}, + "ListRolePolicies": {}, + "TagRole": {}, + "UntagRole": {}, + "ListRoleTags": {}, +} + +// roleTagManager is the AWS-specific role-tagging surface, asserted against the +// provider (not part of the portable IAM driver). +type roleTagManager interface { + TagRole(ctx context.Context, roleName string, tags map[string]string) error + UntagRole(ctx context.Context, roleName string, keys []string) error + ListRoleTags(ctx context.Context, roleName string) (map[string]string, error) +} + +// rolePolicyManager is the AWS-specific inline-role-policy surface. It's not +// part of the portable IAM driver, so the handler type-asserts for it. +type rolePolicyManager interface { + PutRolePolicy(ctx context.Context, roleName, policyName, policyDocument string) error + GetRolePolicy(ctx context.Context, roleName, policyName string) (string, error) + DeleteRolePolicy(ctx context.Context, roleName, policyName string) error + ListRolePolicies(ctx context.Context, roleName string) ([]string, error) } // Handler serves IAM query-protocol requests. @@ -193,6 +218,20 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.addRoleToInstanceProfile(w, r) case "RemoveRoleFromInstanceProfile": h.removeRoleFromInstanceProfile(w, r) + case "PutRolePolicy": + h.putRolePolicy(w, r) + case "GetRolePolicy": + h.getRolePolicy(w, r) + case "DeleteRolePolicy": + h.deleteRolePolicy(w, r) + case "ListRolePolicies": + h.listRolePolicies(w, r) + case "TagRole": + h.tagRole(w, r) + case "UntagRole": + h.untagRole(w, r) + case "ListRoleTags": + h.listRoleTags(w, r) default: awsquery.WriteXMLError(w, http.StatusBadRequest, "InvalidAction", "unknown IAM action: "+r.Form.Get("Action")) diff --git a/server/aws/iam/rolepolicy.go b/server/aws/iam/rolepolicy.go new file mode 100644 index 00000000..29710ff5 --- /dev/null +++ b/server/aws/iam/rolepolicy.go @@ -0,0 +1,131 @@ +package iam + +import ( + "encoding/xml" + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" +) + +type putRolePolicyResponse struct { + XMLName xml.Name `xml:"PutRolePolicyResponse"` + Xmlns string `xml:"xmlns,attr"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type deleteRolePolicyResponse struct { + XMLName xml.Name `xml:"DeleteRolePolicyResponse"` + Xmlns string `xml:"xmlns,attr"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type getRolePolicyResult struct { + RoleName string `xml:"RoleName"` + PolicyName string `xml:"PolicyName"` + PolicyDocument string `xml:"PolicyDocument"` +} + +type getRolePolicyResponse struct { + XMLName xml.Name `xml:"GetRolePolicyResponse"` + Xmlns string `xml:"xmlns,attr"` + Result getRolePolicyResult `xml:"GetRolePolicyResult"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type listRolePoliciesResult struct { + PolicyNames []string `xml:"PolicyNames>member"` +} + +type listRolePoliciesResponse struct { + XMLName xml.Name `xml:"ListRolePoliciesResponse"` + Xmlns string `xml:"xmlns,attr"` + Result listRolePoliciesResult `xml:"ListRolePoliciesResult"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +func (h *Handler) rolePolicies() (rolePolicyManager, bool) { + pm, ok := h.iam.(rolePolicyManager) + + return pm, ok +} + +func (h *Handler) putRolePolicy(w http.ResponseWriter, r *http.Request) { + pm, ok := h.rolePolicies() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "inline role policies not supported")) + return + } + + if err := pm.PutRolePolicy(r.Context(), + r.Form.Get("RoleName"), r.Form.Get("PolicyName"), r.Form.Get("PolicyDocument")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, putRolePolicyResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) getRolePolicy(w http.ResponseWriter, r *http.Request) { + pm, ok := h.rolePolicies() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "inline role policies not supported")) + return + } + + roleName := r.Form.Get("RoleName") + policyName := r.Form.Get("PolicyName") + + doc, err := pm.GetRolePolicy(r.Context(), roleName, policyName) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, getRolePolicyResponse{ + Xmlns: Namespace, + Result: getRolePolicyResult{ + RoleName: roleName, PolicyName: policyName, PolicyDocument: doc, + }, + Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) deleteRolePolicy(w http.ResponseWriter, r *http.Request) { + pm, ok := h.rolePolicies() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "inline role policies not supported")) + return + } + + if err := pm.DeleteRolePolicy(r.Context(), r.Form.Get("RoleName"), r.Form.Get("PolicyName")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, deleteRolePolicyResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) listRolePolicies(w http.ResponseWriter, r *http.Request) { + pm, ok := h.rolePolicies() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "inline role policies not supported")) + return + } + + names, err := pm.ListRolePolicies(r.Context(), r.Form.Get("RoleName")) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, listRolePoliciesResponse{ + Xmlns: Namespace, + Result: listRolePoliciesResult{PolicyNames: names}, + Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} diff --git a/server/aws/iam/roletags.go b/server/aws/iam/roletags.go new file mode 100644 index 00000000..0c9f417a --- /dev/null +++ b/server/aws/iam/roletags.go @@ -0,0 +1,103 @@ +package iam + +import ( + "encoding/xml" + "net/http" + "sort" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" +) + +type tagMemberXML struct { + Key string `xml:"Key"` + Value string `xml:"Value"` +} + +type tagRoleResponse struct { + XMLName xml.Name `xml:"TagRoleResponse"` + Xmlns string `xml:"xmlns,attr"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type untagRoleResponse struct { + XMLName xml.Name `xml:"UntagRoleResponse"` + Xmlns string `xml:"xmlns,attr"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type listRoleTagsResponse struct { + XMLName xml.Name `xml:"ListRoleTagsResponse"` + Xmlns string `xml:"xmlns,attr"` + Tags []tagMemberXML `xml:"ListRoleTagsResult>Tags>member"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +func (h *Handler) roleTags() (roleTagManager, bool) { + m, ok := h.iam.(roleTagManager) + + return m, ok +} + +func (h *Handler) tagRole(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.roleTags() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "role tagging not supported")) + return + } + + if err := mgr.TagRole(r.Context(), r.Form.Get("RoleName"), awsquery.FlatTags(r.Form, "Tags.member")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, tagRoleResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) untagRole(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.roleTags() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "role tagging not supported")) + return + } + + if err := mgr.UntagRole(r.Context(), r.Form.Get("RoleName"), awsquery.ListStrings(r.Form, "TagKeys.member")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, untagRoleResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) listRoleTags(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.roleTags() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "role tagging not supported")) + return + } + + tags, err := mgr.ListRoleTags(r.Context(), r.Form.Get("RoleName")) + if err != nil { + writeErr(w, err) + return + } + + keys := make([]string, 0, len(tags)) + for k := range tags { + keys = append(keys, k) + } + sort.Strings(keys) + + members := make([]tagMemberXML, 0, len(keys)) + for _, k := range keys { + members = append(members, tagMemberXML{Key: k, Value: tags[k]}) + } + + awsquery.WriteXMLResponse(w, listRoleTagsResponse{ + Xmlns: Namespace, Tags: members, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} diff --git a/server/aws/iam/sdk_roundtrip_test.go b/server/aws/iam/sdk_roundtrip_test.go index 07f98d9f..f443d1ff 100644 --- a/server/aws/iam/sdk_roundtrip_test.go +++ b/server/aws/iam/sdk_roundtrip_test.go @@ -110,6 +110,63 @@ func TestSDKIAMUserLifecycle(t *testing.T) { } } +// TestSDKInlineRolePolicy is a regression guard for issue #319: PutRolePolicy +// and the inline-role-policy operations returned InvalidAction. +func TestSDKInlineRolePolicy(t *testing.T) { + client := newSDKClient(t) + ctx := context.Background() + + if _, err := client.CreateRole(ctx, &awsiam.CreateRoleInput{ + RoleName: aws.String("inline-role"), + AssumeRolePolicyDocument: aws.String(trustPolicy), + }); err != nil { + t.Fatalf("CreateRole: %v", err) + } + + if _, err := client.PutRolePolicy(ctx, &awsiam.PutRolePolicyInput{ + RoleName: aws.String("inline-role"), + PolicyName: aws.String("s3access"), + PolicyDocument: aws.String(samplePolicy), + }); err != nil { + t.Fatalf("PutRolePolicy: %v", err) + } + + list, err := client.ListRolePolicies(ctx, &awsiam.ListRolePoliciesInput{RoleName: aws.String("inline-role")}) + if err != nil { + t.Fatalf("ListRolePolicies: %v", err) + } + + if len(list.PolicyNames) != 1 || list.PolicyNames[0] != "s3access" { + t.Fatalf("ListRolePolicies = %v", list.PolicyNames) + } + + got, err := client.GetRolePolicy(ctx, &awsiam.GetRolePolicyInput{ + RoleName: aws.String("inline-role"), PolicyName: aws.String("s3access"), + }) + if err != nil { + t.Fatalf("GetRolePolicy: %v", err) + } + + if aws.ToString(got.PolicyDocument) == "" { + t.Fatal("GetRolePolicy returned empty document") + } + + if _, err := client.DeleteRolePolicy(ctx, &awsiam.DeleteRolePolicyInput{ + RoleName: aws.String("inline-role"), PolicyName: aws.String("s3access"), + }); err != nil { + t.Fatalf("DeleteRolePolicy: %v", err) + } + + list, err = client.ListRolePolicies(ctx, &awsiam.ListRolePoliciesInput{RoleName: aws.String("inline-role")}) + if err != nil { + t.Fatalf("ListRolePolicies after delete: %v", err) + } + + if len(list.PolicyNames) != 0 { + t.Fatalf("ListRolePolicies after delete = %v, want empty", list.PolicyNames) + } +} + func TestSDKIAMRoleAndPolicy(t *testing.T) { client := newSDKClient(t) ctx := context.Background() diff --git a/server/aws/lambda/esm.go b/server/aws/lambda/esm.go new file mode 100644 index 00000000..e711825a --- /dev/null +++ b/server/aws/lambda/esm.go @@ -0,0 +1,115 @@ +package lambda + +import ( + "net/http" + + sdrv "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +type eventSourceMappingJSON struct { + UUID string `json:"UUID"` + EventSourceArn string `json:"EventSourceArn"` + FunctionArn string `json:"FunctionArn,omitempty"` + BatchSize int `json:"BatchSize,omitempty"` + State string `json:"State,omitempty"` + StartingPosition string `json:"StartingPosition,omitempty"` + LastModified string `json:"LastModified,omitempty"` +} + +func toESMJSON(info *sdrv.EventSourceMappingInfo) eventSourceMappingJSON { + return eventSourceMappingJSON{ + UUID: info.UUID, + EventSourceArn: info.EventSourceArn, + FunctionArn: info.FunctionName, + BatchSize: info.BatchSize, + State: info.State, + StartingPosition: info.StartingPosition, + LastModified: info.CreatedAt, + } +} + +// serveEventSourceMappings dispatches the /2015-03-31/event-source-mappings +// paths: collection (POST create, GET list) and per-UUID (GET/DELETE). +func (h *Handler) serveEventSourceMappings(w http.ResponseWriter, r *http.Request, uuid string) { + if uuid == "" { + switch r.Method { + case http.MethodPost: + h.createEventSourceMapping(w, r) + case http.MethodGet: + h.listEventSourceMappings(w, r) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } + + return + } + + switch r.Method { + case http.MethodGet: + info, err := h.fn.GetEventSourceMapping(r.Context(), uuid) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, toESMJSON(info)) + case http.MethodDelete: + if err := h.fn.DeleteEventSourceMapping(r.Context(), uuid); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} + +func (h *Handler) createEventSourceMapping(w http.ResponseWriter, r *http.Request) { + var req struct { + EventSourceArn string `json:"EventSourceArn"` + FunctionName string `json:"FunctionName"` + BatchSize int `json:"BatchSize"` + Enabled *bool `json:"Enabled"` + StartingPosition string `json:"StartingPosition"` + } + + if !decodeJSON(w, r, &req) { + return + } + + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + + info, err := h.fn.CreateEventSourceMapping(r.Context(), sdrv.EventSourceMappingConfig{ + EventSourceArn: req.EventSourceArn, + FunctionName: req.FunctionName, + BatchSize: req.BatchSize, + Enabled: enabled, + StartingPosition: req.StartingPosition, + }) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusCreated, toESMJSON(info)) +} + +func (h *Handler) listEventSourceMappings(w http.ResponseWriter, r *http.Request) { + // FunctionName is an optional filter carried as a query parameter. + infos, err := h.fn.ListEventSourceMappings(r.Context(), r.URL.Query().Get("FunctionName")) + if err != nil { + writeErr(w, err) + return + } + + out := make([]eventSourceMappingJSON, 0, len(infos)) + for i := range infos { + out = append(out, toESMJSON(&infos[i])) + } + + writeJSON(w, http.StatusOK, map[string]any{"EventSourceMappings": out}) +} diff --git a/server/aws/lambda/handler.go b/server/aws/lambda/handler.go index bce82ef0..74dacae3 100644 --- a/server/aws/lambda/handler.go +++ b/server/aws/lambda/handler.go @@ -3,13 +3,18 @@ // registered with this handler and operations work against an in-memory // serverless driver. // -// MVP coverage: CreateFunction, GetFunction, ListFunctions, DeleteFunction, -// Invoke (synchronous). Versions, aliases, layers, concurrency configs, and -// event source mappings are not yet wired through — the driver supports them -// but the wire surface is deferred to a follow-up. +// Coverage: CreateFunction, GetFunction, ListFunctions, DeleteFunction, +// Invoke (synchronous), UpdateFunctionConfiguration, PublishVersion / +// ListVersionsByFunction, the alias lifecycle (create/get/list/update/ +// delete), and resource policies (AddPermission / GetPolicy / +// RemovePermission), and tagging (TagResource / UntagResource / ListTags at +// the /2017-03-31/tags prefix). Layers, concurrency configs, and event source +// mappings remain deferred — the driver supports some of them but the wire +// surface is not yet wired through. package lambda import ( + "context" "encoding/json" "io" "net/http" @@ -24,11 +29,39 @@ import ( // REST traffic that should fall through to the S3 catch-all. const pathPrefix = "/2015-03-31/functions" +// tagsPrefix is the Lambda tagging API prefix (TagResource / UntagResource / +// ListTags). It's a different version prefix than the function control plane, +// so it needs its own Matches clause — otherwise tag requests fall through to +// the S3 catch-all and return a 405 HTML body the SDK can't deserialize. +const tagsPrefix = "/2017-03-31/tags" + +// esmPrefix is the Lambda event-source-mapping API prefix (SQS/DynamoDB-stream +// -> Lambda triggers). Its own version prefix, so it needs a Matches clause. +const esmPrefix = "/2015-03-31/event-source-mappings" + const ( contentTypeJSON = "application/json" maxBodyBytes = 6 << 20 // 6 MiB — Lambda's sync invocation payload limit. ) +// policyManager is the AWS-specific resource-policy surface (AddPermission / +// GetPolicy / RemovePermission). It's not part of the portable Serverless +// driver — resource policies are a Lambda concept — so the handler type-asserts +// for it rather than requiring every cloud's function provider to implement it. +type policyManager interface { + AddPermission(ctx context.Context, functionName string, stmt sdrv.PermissionStatement) error + RemovePermission(ctx context.Context, functionName, statementID string) error + GetPolicy(ctx context.Context, functionName string) (string, error) +} + +// functionTagger is the AWS-specific Lambda tagging surface (not part of the +// portable Serverless driver), asserted the same way as policyManager. +type functionTagger interface { + TagFunction(ctx context.Context, name string, tags map[string]string) error + UntagFunction(ctx context.Context, name string, keys []string) error + ListFunctionTags(ctx context.Context, name string) (map[string]string, error) +} + // Handler serves AWS Lambda REST requests against a serverless.Serverless // driver. type Handler struct { @@ -43,7 +76,9 @@ func New(fn sdrv.Serverless) *Handler { // Matches returns true for any URL under /2015-03-31/functions — that's the // Lambda control-plane prefix the SDK uses for every operation in our MVP. func (*Handler) Matches(r *http.Request) bool { - return strings.HasPrefix(r.URL.Path, pathPrefix) + return strings.HasPrefix(r.URL.Path, pathPrefix) || + strings.HasPrefix(r.URL.Path, tagsPrefix) || + strings.HasPrefix(r.URL.Path, esmPrefix) } // ServeHTTP dispatches Lambda operations based on path shape and method. @@ -52,6 +87,20 @@ func (*Handler) Matches(r *http.Request) bool { // /2015-03-31/functions/{name} GET=get, DELETE=delete // /2015-03-31/functions/{name}/invocations POST=invoke func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, tagsPrefix) { + arn := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, tagsPrefix), "/") + h.serveTags(w, r, arn) + + return + } + + if strings.HasPrefix(r.URL.Path, esmPrefix) { + uuid := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, esmPrefix), "/") + h.serveEventSourceMappings(w, r, uuid) + + return + } + rest := strings.TrimPrefix(r.URL.Path, pathPrefix) rest = strings.TrimPrefix(rest, "/") @@ -64,25 +113,351 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { name := parts[0] const ( - partsResource = 1 // /functions/{name} - partsInvoke = 2 // /functions/{name}/invocations + partsResource = 1 // /functions/{name} + partsSubresource = 2 // /functions/{name}/{sub} + partsSubItem = 3 // /functions/{name}/{sub}/{id} ) switch len(parts) { case partsResource: h.serveResource(w, r, name) - case partsInvoke: - if parts[1] == "invocations" { - h.serveInvoke(w, r, name) - return + case partsSubresource: + h.serveSubresource(w, r, name, parts[1]) + case partsSubItem: + switch parts[1] { + case "aliases": + h.serveAlias(w, r, name, parts[2]) + case "policy": + h.serveRemovePermission(w, r, name, parts[2]) + default: + writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") } - + default: writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") + } +} + +// serveSubresource dispatches /functions/{name}/{sub} paths. +func (h *Handler) serveSubresource(w http.ResponseWriter, r *http.Request, name, sub string) { + switch sub { + case "invocations": + h.serveInvoke(w, r, name) + case "configuration": + h.serveConfiguration(w, r, name) + case "versions": + h.serveVersions(w, r, name) + case "aliases": + h.serveAliases(w, r, name) + case "policy": + h.servePolicy(w, r, name) default: writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") } } +// serveTags handles the Lambda tagging API at /2017-03-31/tags/{arn}: +// POST=TagResource, DELETE=UntagResource (?tagKeys=...), GET=ListTags. +func (h *Handler) serveTags(w http.ResponseWriter, r *http.Request, arn string) { + tagger, ok := h.fn.(functionTagger) + if !ok { + writeError(w, http.StatusNotImplemented, "InvalidRequestException", "tagging not supported") + return + } + + name := functionNameFromARN(arn) + + switch r.Method { + case http.MethodPost: + var req struct { + Tags map[string]string `json:"Tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + if err := tagger.TagFunction(r.Context(), name, req.Tags); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + case http.MethodDelete: + if err := tagger.UntagFunction(r.Context(), name, r.URL.Query()["tagKeys"]); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + case http.MethodGet: + tags, err := tagger.ListFunctionTags(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"Tags": tags}) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} + +// functionNameFromARN extracts the function name from a Lambda ARN +// (arn:aws:lambda:::function:). A value that isn't an +// ARN is returned unchanged. +func functionNameFromARN(arn string) string { + const marker = ":function:" + + if i := strings.LastIndex(arn, marker); i >= 0 { + return arn[i+len(marker):] + } + + return arn +} + +// servePolicy handles POST (AddPermission) and GET (GetPolicy) on +// .../{name}/policy. +func (h *Handler) servePolicy(w http.ResponseWriter, r *http.Request, name string) { + pm, ok := h.fn.(policyManager) + if !ok { + writeError(w, http.StatusNotImplemented, "InvalidRequestException", "resource policies not supported") + return + } + + switch r.Method { + case http.MethodPost: + var req addPermissionRequest + if !decodeJSON(w, r, &req) { + return + } + + err := pm.AddPermission(r.Context(), name, sdrv.PermissionStatement{ + StatementID: req.StatementID, Action: req.Action, + Principal: req.Principal, SourceARN: req.SourceArn, + }) + if err != nil { + writeErr(w, err) + return + } + + stmt, jerr := json.Marshal(map[string]any{ + "Sid": req.StatementID, + "Effect": "Allow", + "Principal": map[string]string{"Service": req.Principal}, + "Action": req.Action, + }) + if jerr != nil { + writeErr(w, jerr) + return + } + + writeJSON(w, http.StatusCreated, map[string]string{"Statement": string(stmt)}) + case http.MethodGet: + policy, err := pm.GetPolicy(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"Policy": policy, "RevisionId": "1"}) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} + +// serveRemovePermission handles DELETE .../{name}/policy/{statementId}. +func (h *Handler) serveRemovePermission(w http.ResponseWriter, r *http.Request, name, statementID string) { + if r.Method != http.MethodDelete { + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + return + } + + pm, ok := h.fn.(policyManager) + if !ok { + writeError(w, http.StatusNotImplemented, "InvalidRequestException", "resource policies not supported") + return + } + + if err := pm.RemovePermission(r.Context(), name, statementID); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// serveConfiguration handles PUT .../{name}/configuration +// (UpdateFunctionConfiguration). +func (h *Handler) serveConfiguration(w http.ResponseWriter, r *http.Request, name string) { + if r.Method != http.MethodPut { + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + return + } + + var req updateFunctionConfigurationRequest + if !decodeJSON(w, r, &req) { + return + } + + cfg := sdrv.FunctionConfig{ + Name: name, + Runtime: req.Runtime, + Handler: req.Handler, + Memory: req.MemorySize, + Timeout: req.Timeout, + } + if req.Environment != nil { + cfg.Environment = req.Environment.Variables + } + + info, err := h.fn.UpdateFunction(r.Context(), name, cfg) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, toConfiguration(info)) +} + +// serveVersions handles POST (PublishVersion) and GET (ListVersionsByFunction) +// on .../{name}/versions. +func (h *Handler) serveVersions(w http.ResponseWriter, r *http.Request, name string) { + switch r.Method { + case http.MethodPost: + var req publishVersionRequest + if !decodeJSON(w, r, &req) { + return + } + + ver, err := h.fn.PublishVersion(r.Context(), name, req.Description) + if err != nil { + writeErr(w, err) + return + } + + info, err := h.fn.GetFunction(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + cfg := toConfiguration(info) + cfg.Version = ver.Version + cfg.Description = ver.Description + writeJSON(w, http.StatusCreated, cfg) + case http.MethodGet: + vers, err := h.fn.ListVersions(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + info, err := h.fn.GetFunction(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + out := listVersionsResponse{Versions: make([]functionConfiguration, 0, len(vers))} + for i := range vers { + cfg := toConfiguration(info) + cfg.Version = vers[i].Version + cfg.Description = vers[i].Description + out.Versions = append(out.Versions, cfg) + } + + writeJSON(w, http.StatusOK, out) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} + +// serveAliases handles POST (CreateAlias) and GET (ListAliases) on +// .../{name}/aliases. +func (h *Handler) serveAliases(w http.ResponseWriter, r *http.Request, name string) { + switch r.Method { + case http.MethodPost: + var req aliasRequest + if !decodeJSON(w, r, &req) { + return + } + + a, err := h.fn.CreateAlias(r.Context(), sdrv.AliasConfig{ + FunctionName: name, Name: req.Name, + FunctionVersion: req.FunctionVersion, Description: req.Description, + }) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusCreated, toAliasResponse(a)) + case http.MethodGet: + aliases, err := h.fn.ListAliases(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + out := listAliasesResponse{Aliases: make([]aliasResponse, 0, len(aliases))} + for i := range aliases { + out.Aliases = append(out.Aliases, toAliasResponse(&aliases[i])) + } + + writeJSON(w, http.StatusOK, out) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} + +// serveAlias handles GET/PUT/DELETE on .../{name}/aliases/{aliasName}. +func (h *Handler) serveAlias(w http.ResponseWriter, r *http.Request, name, aliasName string) { + switch r.Method { + case http.MethodGet: + a, err := h.fn.GetAlias(r.Context(), name, aliasName) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, toAliasResponse(a)) + case http.MethodPut: + var req aliasRequest + if !decodeJSON(w, r, &req) { + return + } + + a, err := h.fn.UpdateAlias(r.Context(), sdrv.AliasConfig{ + FunctionName: name, Name: aliasName, + FunctionVersion: req.FunctionVersion, Description: req.Description, + }) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, toAliasResponse(a)) + case http.MethodDelete: + if err := h.fn.DeleteAlias(r.Context(), name, aliasName); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} + +func toAliasResponse(a *sdrv.Alias) aliasResponse { + return aliasResponse{ + AliasArn: a.AliasARN, + Name: a.Name, + FunctionVersion: a.FunctionVersion, + Description: a.Description, + } +} + func (h *Handler) serveCollection(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: diff --git a/server/aws/lambda/lambda_test.go b/server/aws/lambda/lambda_test.go index 8003e096..1a6a8176 100644 --- a/server/aws/lambda/lambda_test.go +++ b/server/aws/lambda/lambda_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" @@ -234,7 +235,11 @@ func TestInvokeReturnsHandlerPayload(t *testing.T) { } } -func TestInvokeMissingHandlerSignalsError(t *testing.T) { +// TestInvokeNoHandlerEchoesStub is a regression guard for issue #319: with no +// Go handler registered, invoke used to return a FunctionError ("no handler +// registered"). The emulator can't run an uploaded zip, so it now returns a +// successful stub that echoes the request payload — invoke is testable. +func TestInvokeNoHandlerEchoesStub(t *testing.T) { srv, _ := newServer(t) if r := postJSON(t, srv.URL+"/2015-03-31/functions", @@ -243,18 +248,69 @@ func TestInvokeMissingHandlerSignalsError(t *testing.T) { } resp, err := http.Post(srv.URL+"/2015-03-31/functions/nohandler/invocations", - "application/json", bytes.NewReader([]byte(`{}`))) + "application/json", bytes.NewReader([]byte(`{"hi":1}`))) if err != nil { t.Fatalf("invoke: %v", err) } defer resp.Body.Close() - if resp.Header.Get("X-Amz-Function-Error") == "" { - t.Fatal("expected X-Amz-Function-Error on no-handler invoke") + if resp.StatusCode != http.StatusOK { + t.Fatalf("invoke status = %d, want 200", resp.StatusCode) + } + + if resp.Header.Get("X-Amz-Function-Error") != "" { + t.Fatal("no-handler invoke must not signal a FunctionError") + } + + body, _ := io.ReadAll(resp.Body) + if string(body) != `{"hi":1}` { + t.Fatalf("stub invoke body = %q, want the echoed payload", string(body)) } } +// TestEventSourceMappings is a regression guard for issue #319: +// CreateEventSourceMapping (and the ESM lifecycle) returned 405. +func TestEventSourceMappings(t *testing.T) { + srv, _ := newServer(t) + + if r := postJSON(t, srv.URL+"/2015-03-31/functions", + `{"FunctionName":"fx","Runtime":"go1.x"}`); r.StatusCode != http.StatusCreated { + t.Fatalf("create fn: %d", r.StatusCode) + } + + esmURL := srv.URL + esmBasePath + create := postJSON(t, esmURL, + `{"FunctionName":"fx","EventSourceArn":"arn:aws:sqs:us-east-1:000000000000:q","BatchSize":5}`) + if create.StatusCode != http.StatusCreated { + t.Fatalf("create ESM status = %d", create.StatusCode) + } + + var esm struct { + UUID string `json:"UUID"` + State string `json:"State"` + } + + decode(t, create, &esm) + + if esm.UUID == "" { + t.Fatal("CreateEventSourceMapping returned empty UUID") + } + + // GET by UUID. + got := doJSON(t, http.MethodGet, esmURL+"/"+esm.UUID, "") + if got.StatusCode != http.StatusOK { + t.Fatalf("get ESM status = %d", got.StatusCode) + } + + // DELETE by UUID. + if del := doJSON(t, http.MethodDelete, esmURL+"/"+esm.UUID, ""); del.StatusCode != http.StatusNoContent { + t.Fatalf("delete ESM status = %d", del.StatusCode) + } +} + +const esmBasePath = "/2015-03-31/event-source-mappings" + func TestInvokeOnMissingFunctionReturns404(t *testing.T) { srv, _ := newServer(t) @@ -325,6 +381,8 @@ type functionShape struct { FunctionArn string `json:"FunctionArn"` Runtime string `json:"Runtime"` Handler string `json:"Handler"` + Timeout int `json:"Timeout"` + Version string `json:"Version"` Environment *envShape `json:"Environment"` } @@ -338,3 +396,186 @@ func postJSON(t *testing.T, url, body string) *http.Response { return resp } + +func doJSON(t *testing.T, method, url, body string) *http.Response { + t.Helper() + + req, err := http.NewRequest(method, url, strings.NewReader(body)) + if err != nil { + t.Fatalf("new %s %s: %v", method, url, err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, url, err) + } + + return resp +} + +// TestConfigurationVersionsAliases is a regression guard for issue #319: the +// Lambda handler previously returned 404 "unsupported Lambda path" for +// UpdateFunctionConfiguration, PublishVersion, and the alias sub-resources. +func TestConfigurationVersionsAliases(t *testing.T) { + srv, _ := newServer(t) + base := srv.URL + "/2015-03-31/functions" + + if resp := postJSON(t, base, + `{"FunctionName":"fn","Runtime":"go1.x","Handler":"main","Timeout":10}`); resp.StatusCode != http.StatusCreated { + t.Fatalf("create status = %d", resp.StatusCode) + } + + // UpdateFunctionConfiguration. + resp := doJSON(t, http.MethodPut, base+"/fn/configuration", `{"Timeout":60,"MemorySize":256}`) + if resp.StatusCode != http.StatusOK { + t.Fatalf("update-configuration status = %d", resp.StatusCode) + } + + var cfg functionShape + decode(t, resp, &cfg) + + if cfg.Timeout != 60 { + t.Fatalf("Timeout = %d, want 60", cfg.Timeout) + } + + // PublishVersion. + resp = postJSON(t, base+"/fn/versions", `{"Description":"v1"}`) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("publish-version status = %d", resp.StatusCode) + } + + var ver functionShape + decode(t, resp, &ver) + + if ver.Version != "1" { + t.Fatalf("Version = %q, want 1", ver.Version) + } + + // CreateAlias + GetAlias. + resp = postJSON(t, base+"/fn/aliases", `{"Name":"prod","FunctionVersion":"1"}`) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create-alias status = %d", resp.StatusCode) + } + + resp = doJSON(t, http.MethodGet, base+"/fn/aliases/prod", "") + + var alias struct { + Name string `json:"Name"` + FunctionVersion string `json:"FunctionVersion"` + AliasArn string `json:"AliasArn"` + } + + decode(t, resp, &alias) + + if alias.Name != "prod" || alias.FunctionVersion != "1" { + t.Fatalf("get-alias = %+v", alias) + } + + if !strings.Contains(alias.AliasArn, ":function:fn:prod") { + t.Fatalf("AliasArn = %q", alias.AliasArn) + } +} + +// TestResourcePolicy is a regression guard for issue #319: AddPermission, +// GetPolicy, and RemovePermission (Terraform's aws_lambda_permission) were +// unreachable. It also verifies the AWS-local policyManager assertion path. +func TestResourcePolicy(t *testing.T) { + srv, _ := newServer(t) + base := srv.URL + "/2015-03-31/functions" + + if resp := postJSON(t, base, + `{"FunctionName":"pf","Runtime":"go1.x","Handler":"main"}`); resp.StatusCode != http.StatusCreated { + t.Fatalf("create status = %d", resp.StatusCode) + } + + // AddPermission. + resp := postJSON(t, base+"/pf/policy", + `{"StatementId":"s3invoke","Action":"lambda:InvokeFunction","Principal":"s3.amazonaws.com","SourceArn":"arn:aws:s3:::b"}`) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("add-permission status = %d", resp.StatusCode) + } + + // GetPolicy surfaces the statement. + resp = doJSON(t, http.MethodGet, base+"/pf/policy", "") + + var got struct { + Policy string `json:"Policy"` + } + + decode(t, resp, &got) + + if !strings.Contains(got.Policy, `"Sid":"s3invoke"`) { + t.Fatalf("policy missing statement: %s", got.Policy) + } + + // RemovePermission, then GetPolicy must 404. + if resp := doJSON(t, http.MethodDelete, base+"/pf/policy/s3invoke", ""); resp.StatusCode != http.StatusNoContent { + t.Fatalf("remove-permission status = %d", resp.StatusCode) + } + + if resp := doJSON(t, http.MethodGet, base+"/pf/policy", ""); resp.StatusCode != http.StatusNotFound { + t.Fatalf("get-policy after remove status = %d, want 404", resp.StatusCode) + } +} + +// TestTagging is a regression guard for issue #319: the Lambda tagging API +// (/2017-03-31/tags/{arn}) was unmatched, so it fell through to the S3 +// catch-all and returned a 405 + HTML body the SDK couldn't deserialize. +func TestTagging(t *testing.T) { + srv, _ := newServer(t) + + if resp := postJSON(t, srv.URL+"/2015-03-31/functions", + `{"FunctionName":"tf","Runtime":"go1.x","Handler":"main"}`); resp.StatusCode != http.StatusCreated { + t.Fatalf("create status = %d", resp.StatusCode) + } + + // The SDK percent-encodes the ARN in the path; mirror that here so the + // server's URL parser keeps the query string separate. + tagsURL := srv.URL + "/2017-03-31/tags/" + + url.PathEscape("arn:aws:lambda:us-east-1:000000000000:function:tf") + + // TagResource. + if resp := postJSON(t, tagsURL, `{"Tags":{"env":"prod","team":"sls"}}`); resp.StatusCode != http.StatusNoContent { + t.Fatalf("tag-resource status = %d", resp.StatusCode) + } + + // ListTags. + resp := doJSON(t, http.MethodGet, tagsURL, "") + + var got struct { + Tags map[string]string `json:"Tags"` + } + + decode(t, resp, &got) + + if got.Tags["env"] != "prod" || got.Tags["team"] != "sls" { + t.Fatalf("ListTags = %+v", got.Tags) + } + + // UntagResource. + if resp := doJSON(t, http.MethodDelete, tagsURL+"?tagKeys=env", ""); resp.StatusCode != http.StatusNoContent { + t.Fatalf("untag-resource status = %d", resp.StatusCode) + } + + var after struct { + Tags map[string]string `json:"Tags"` + } + + decode(t, doJSON(t, http.MethodGet, tagsURL, ""), &after) + + if _, has := after.Tags["env"]; has || after.Tags["team"] != "sls" { + t.Fatalf("after untag = %+v", after.Tags) + } +} + +func decode(t *testing.T, resp *http.Response, v any) { + t.Helper() + + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(v); err != nil { + t.Fatalf("decode: %v", err) + } +} diff --git a/server/aws/lambda/types.go b/server/aws/lambda/types.go index 0b28a03b..2b55cd86 100644 --- a/server/aws/lambda/types.go +++ b/server/aws/lambda/types.go @@ -21,6 +21,57 @@ type functionConfiguration struct { CodeSha256 string `json:"CodeSha256,omitempty"` Environment *envEnvelope `json:"Environment,omitempty"` PackageType string `json:"PackageType,omitempty"` + Version string `json:"Version,omitempty"` +} + +// updateFunctionConfigurationRequest captures the mutable fields of +// UpdateFunctionConfiguration (PUT .../{name}/configuration). +type updateFunctionConfigurationRequest struct { + Runtime string `json:"Runtime"` + Role string `json:"Role"` + Handler string `json:"Handler"` + Description string `json:"Description"` + MemorySize int `json:"MemorySize"` + Timeout int `json:"Timeout"` + Environment *envEnvelope `json:"Environment"` +} + +// publishVersionRequest is the body of PublishVersion (POST .../{name}/versions). +type publishVersionRequest struct { + Description string `json:"Description"` +} + +// listVersionsResponse is the ListVersionsByFunction envelope. +type listVersionsResponse struct { + Versions []functionConfiguration `json:"Versions"` +} + +// aliasRequest is the body of Create/UpdateAlias. +type aliasRequest struct { + Name string `json:"Name"` + FunctionVersion string `json:"FunctionVersion"` + Description string `json:"Description"` +} + +// aliasResponse is the AWS AliasConfiguration shape. +type aliasResponse struct { + AliasArn string `json:"AliasArn"` + Name string `json:"Name"` + FunctionVersion string `json:"FunctionVersion"` + Description string `json:"Description,omitempty"` +} + +// listAliasesResponse is the ListAliases envelope. +type listAliasesResponse struct { + Aliases []aliasResponse `json:"Aliases"` +} + +// addPermissionRequest is the body of AddPermission (POST .../{name}/policy). +type addPermissionRequest struct { + StatementID string `json:"StatementId"` + Action string `json:"Action"` + Principal string `json:"Principal"` + SourceArn string `json:"SourceArn"` } // functionResource is the shape returned by GetFunction: diff --git a/server/aws/networkfirewall/depth.go b/server/aws/networkfirewall/depth.go new file mode 100644 index 00000000..126c7c6f --- /dev/null +++ b/server/aws/networkfirewall/depth.go @@ -0,0 +1,224 @@ +package networkfirewall + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire" + nfdriver "github.com/stackshy/cloudemu/v2/services/networkfirewall/driver" +) + +type associatePolicyRequest struct { + FirewallName string `json:"FirewallName"` + FirewallArn string `json:"FirewallArn"` + FirewallPolicyArn string `json:"FirewallPolicyArn"` +} + +func (h *Handler) associateFirewallPolicy(w http.ResponseWriter, r *http.Request) { + var req associatePolicyRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + fw, err := h.db.AssociateFirewallPolicy(r.Context(), firewallName(req.FirewallName, req.FirewallArn), req.FirewallPolicyArn) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{ + "FirewallArn": fw.ARN, "FirewallName": fw.Name, + "FirewallPolicyArn": fw.PolicyARN, "UpdateToken": updateToken, + }) +} + +type subnetsRequest struct { + FirewallName string `json:"FirewallName"` + FirewallArn string `json:"FirewallArn"` + SubnetMappings []subnetMapping `json:"SubnetMappings"` + SubnetIDs []string `json:"SubnetIds"` +} + +func (h *Handler) associateSubnets(w http.ResponseWriter, r *http.Request) { + var req subnetsRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + fw, err := h.db.AssociateSubnets(r.Context(), firewallName(req.FirewallName, req.FirewallArn), subnetIDs(req.SubnetMappings)) + if err != nil { + writeErr(w, err) + return + } + + writeSubnetsResult(w, fw) +} + +func (h *Handler) disassociateSubnets(w http.ResponseWriter, r *http.Request) { + var req subnetsRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + fw, err := h.db.DisassociateSubnets(r.Context(), firewallName(req.FirewallName, req.FirewallArn), req.SubnetIDs) + if err != nil { + writeErr(w, err) + return + } + + writeSubnetsResult(w, fw) +} + +func writeSubnetsResult(w http.ResponseWriter, fw *nfdriver.Firewall) { + mappings := make([]subnetMapping, 0, len(fw.SubnetIDs)) + for _, s := range fw.SubnetIDs { + mappings = append(mappings, subnetMapping{SubnetID: s}) + } + + wire.WriteJSON(w, map[string]any{ + "FirewallArn": fw.ARN, "FirewallName": fw.Name, + "SubnetMappings": mappings, "UpdateToken": updateToken, + }) +} + +type deleteProtectionRequest struct { + FirewallName string `json:"FirewallName"` + FirewallArn string `json:"FirewallArn"` + DeleteProtection bool `json:"DeleteProtection"` +} + +func (h *Handler) updateFirewallDeleteProtection(w http.ResponseWriter, r *http.Request) { + var req deleteProtectionRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + fw, err := h.db.UpdateFirewallDeleteProtection(r.Context(), firewallName(req.FirewallName, req.FirewallArn), req.DeleteProtection) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{ + "FirewallArn": fw.ARN, "FirewallName": fw.Name, + "DeleteProtection": fw.DeleteProtection, "UpdateToken": updateToken, + }) +} + +type logDestinationConfig struct { + LogType string `json:"LogType"` + LogDestinationType string `json:"LogDestinationType,omitempty"` + LogDestination map[string]string `json:"LogDestination,omitempty"` +} + +type loggingConfiguration struct { + LogDestinationConfigs []logDestinationConfig `json:"LogDestinationConfigs"` +} + +type loggingRequest struct { + FirewallName string `json:"FirewallName"` + FirewallArn string `json:"FirewallArn"` + LoggingConfiguration loggingConfiguration `json:"LoggingConfiguration"` +} + +func (h *Handler) updateLoggingConfiguration(w http.ResponseWriter, r *http.Request) { + var req loggingRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + logTypes := make([]string, 0, len(req.LoggingConfiguration.LogDestinationConfigs)) + for _, c := range req.LoggingConfiguration.LogDestinationConfigs { + logTypes = append(logTypes, c.LogType) + } + + name := firewallName(req.FirewallName, req.FirewallArn) + if err := h.db.UpdateLoggingConfiguration(r.Context(), name, logTypes); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{ + "FirewallArn": req.FirewallArn, "FirewallName": name, + "LoggingConfiguration": req.LoggingConfiguration, + }) +} + +func (h *Handler) describeLoggingConfiguration(w http.ResponseWriter, r *http.Request) { + var req nameArnRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + name := firewallName(req.FirewallName, req.FirewallArn) + + logTypes, err := h.db.DescribeLoggingConfiguration(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + configs := make([]logDestinationConfig, 0, len(logTypes)) + for _, lt := range logTypes { + configs = append(configs, logDestinationConfig{LogType: lt}) + } + + wire.WriteJSON(w, map[string]any{ + "FirewallArn": req.FirewallArn, + "LoggingConfiguration": loggingConfiguration{LogDestinationConfigs: configs}, + }) +} + +type tagResourceRequest struct { + ResourceArn string `json:"ResourceArn"` + Tags []tag `json:"Tags"` +} + +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request) { + var req tagResourceRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := h.db.TagResource(r.Context(), req.ResourceArn, tagsToMap(req.Tags)); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{}) +} + +type untagResourceRequest struct { + ResourceArn string `json:"ResourceArn"` + TagKeys []string `json:"TagKeys"` +} + +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { + var req untagResourceRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := h.db.UntagResource(r.Context(), req.ResourceArn, req.TagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{}) +} + +// firewallName resolves the firewall's store key (its name) from either the +// name or the ARN provided by the caller. Firewalls are keyed by name. +func firewallName(name, arn string) string { + if name != "" { + return name + } + + // ARN tail after "firewall/" is the name. + for i := len(arn) - 1; i >= 0; i-- { + if arn[i] == '/' { + return arn[i+1:] + } + } + + return arn +} diff --git a/server/aws/networkfirewall/handler.go b/server/aws/networkfirewall/handler.go new file mode 100644 index 00000000..2c0ed735 --- /dev/null +++ b/server/aws/networkfirewall/handler.go @@ -0,0 +1,100 @@ +// Package networkfirewall implements the AWS Network Firewall control-plane API +// as a server.Handler. Network Firewall uses AWS JSON 1.0 with the X-Amz-Target +// prefix "NetworkFirewall_20201112.", so real aws-sdk-go-v2 networkfirewall +// clients configured with a custom endpoint hit this handler unchanged. +package networkfirewall + +import ( + "net/http" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire" + nfdriver "github.com/stackshy/cloudemu/v2/services/networkfirewall/driver" +) + +const targetPrefix = "NetworkFirewall_20201112." + +// Handler serves Network Firewall requests against a networkfirewall driver. +type Handler struct { + db nfdriver.NetworkFirewall +} + +// New returns a Network Firewall handler backed by db. +func New(db nfdriver.NetworkFirewall) *Handler { + return &Handler{db: db} +} + +// Matches claims requests whose X-Amz-Target names a Network Firewall operation. +func (*Handler) Matches(r *http.Request) bool { + return strings.HasPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) +} + +// ServeHTTP dispatches on the operation named in X-Amz-Target. +// +//nolint:gocyclo // a flat operation switch is the clearest dispatch shape. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) + + switch op { + case "CreateFirewall": + h.createFirewall(w, r) + case "DescribeFirewall": + h.describeFirewall(w, r) + case "DeleteFirewall": + h.deleteFirewall(w, r) + case "ListFirewalls": + h.listFirewalls(w, r) + case "CreateFirewallPolicy": + h.createFirewallPolicy(w, r) + case "DescribeFirewallPolicy": + h.describeFirewallPolicy(w, r) + case "DeleteFirewallPolicy": + h.deleteFirewallPolicy(w, r) + case "ListFirewallPolicies": + h.listFirewallPolicies(w, r) + case "CreateRuleGroup": + h.createRuleGroup(w, r) + case "DescribeRuleGroup": + h.describeRuleGroup(w, r) + case "DeleteRuleGroup": + h.deleteRuleGroup(w, r) + case "ListRuleGroups": + h.listRuleGroups(w, r) + case "AssociateFirewallPolicy": + h.associateFirewallPolicy(w, r) + case "AssociateSubnets": + h.associateSubnets(w, r) + case "DisassociateSubnets": + h.disassociateSubnets(w, r) + case "UpdateFirewallDeleteProtection": + h.updateFirewallDeleteProtection(w, r) + case "UpdateLoggingConfiguration": + h.updateLoggingConfiguration(w, r) + case "DescribeLoggingConfiguration": + h.describeLoggingConfiguration(w, r) + case "TagResource": + h.tagResource(w, r) + case "UntagResource": + h.untagResource(w, r) + default: + wire.WriteJSONError(w, http.StatusBadRequest, "InvalidRequestException", "unknown operation: "+op) + } +} + +const updateToken = "00000000-0000-0000-0000-000000000000" + +func writeErr(w http.ResponseWriter, err error) { + msg := err.Error() + + switch { + case cerrors.IsNotFound(err): + wire.WriteJSONError(w, http.StatusBadRequest, "ResourceNotFoundException", msg) + case cerrors.IsAlreadyExists(err): + wire.WriteJSONError(w, http.StatusBadRequest, "InvalidOperationException", msg) + case cerrors.IsInvalidArgument(err), cerrors.IsFailedPrecondition(err): + wire.WriteJSONError(w, http.StatusBadRequest, "InvalidRequestException", msg) + default: + wire.WriteJSONError(w, http.StatusInternalServerError, "InternalServerError", msg) + } +} diff --git a/server/aws/networkfirewall/operations.go b/server/aws/networkfirewall/operations.go new file mode 100644 index 00000000..ee774777 --- /dev/null +++ b/server/aws/networkfirewall/operations.go @@ -0,0 +1,393 @@ +package networkfirewall + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire" + nfdriver "github.com/stackshy/cloudemu/v2/services/networkfirewall/driver" +) + +type tag struct { + Key string `json:"Key"` + Value string `json:"Value"` +} + +type subnetMapping struct { + SubnetID string `json:"SubnetId"` +} + +// ---- Firewall ---- + +type firewallJSON struct { + FirewallName string `json:"FirewallName"` + FirewallArn string `json:"FirewallArn"` + FirewallPolicyArn string `json:"FirewallPolicyArn,omitempty"` + VpcID string `json:"VpcId,omitempty"` + SubnetMappings []subnetMapping `json:"SubnetMappings,omitempty"` + Description string `json:"Description,omitempty"` + DeleteProtection bool `json:"DeleteProtection"` + Tags []tag `json:"Tags,omitempty"` +} + +type firewallStatusJSON struct { + Status string `json:"Status"` +} + +type createFirewallRequest struct { + FirewallName string `json:"FirewallName"` + FirewallPolicyArn string `json:"FirewallPolicyArn"` + VpcID string `json:"VpcId"` + SubnetMappings []subnetMapping `json:"SubnetMappings"` + Description string `json:"Description"` + DeleteProtection bool `json:"DeleteProtection"` + Tags []tag `json:"Tags"` +} + +type nameArnRequest struct { + FirewallName string `json:"FirewallName"` + FirewallArn string `json:"FirewallArn"` +} + +func (h *Handler) createFirewall(w http.ResponseWriter, r *http.Request) { + var req createFirewallRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + fw, err := h.db.CreateFirewall(r.Context(), nfdriver.CreateFirewallConfig{ + Name: req.FirewallName, + PolicyARN: req.FirewallPolicyArn, + VPCID: req.VpcID, + SubnetIDs: subnetIDs(req.SubnetMappings), + Description: req.Description, + DeleteProtection: req.DeleteProtection, + Tags: tagsToMap(req.Tags), + }) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{ + "Firewall": toFirewallJSON(fw), + "FirewallStatus": firewallStatusJSON{Status: fw.Status}, + }) +} + +func (h *Handler) describeFirewall(w http.ResponseWriter, r *http.Request) { + var req nameArnRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + fw, err := h.db.DescribeFirewall(r.Context(), req.FirewallName, req.FirewallArn) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{ + "UpdateToken": updateToken, + "Firewall": toFirewallJSON(fw), + "FirewallStatus": firewallStatusJSON{Status: fw.Status}, + }) +} + +func (h *Handler) deleteFirewall(w http.ResponseWriter, r *http.Request) { + var req nameArnRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + fw, err := h.db.DeleteFirewall(r.Context(), req.FirewallName, req.FirewallArn) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{ + "Firewall": toFirewallJSON(fw), + "FirewallStatus": firewallStatusJSON{Status: fw.Status}, + }) +} + +func (h *Handler) listFirewalls(w http.ResponseWriter, r *http.Request) { + items, err := h.db.ListFirewalls(r.Context()) + if err != nil { + writeErr(w, err) + return + } + + metas := make([]map[string]string, 0, len(items)) + for i := range items { + metas = append(metas, map[string]string{"FirewallName": items[i].Name, "FirewallArn": items[i].ARN}) + } + + wire.WriteJSON(w, map[string]any{"Firewalls": metas}) +} + +func toFirewallJSON(f *nfdriver.Firewall) firewallJSON { + mappings := make([]subnetMapping, 0, len(f.SubnetIDs)) + for _, s := range f.SubnetIDs { + mappings = append(mappings, subnetMapping{SubnetID: s}) + } + + return firewallJSON{ + FirewallName: f.Name, FirewallArn: f.ARN, FirewallPolicyArn: f.PolicyARN, + VpcID: f.VPCID, SubnetMappings: mappings, Description: f.Description, + DeleteProtection: f.DeleteProtection, Tags: mapToTags(f.Tags), + } +} + +// ---- Firewall Policy ---- + +type firewallPolicyResponseJSON struct { + FirewallPolicyName string `json:"FirewallPolicyName"` + FirewallPolicyArn string `json:"FirewallPolicyArn"` + FirewallPolicyID string `json:"FirewallPolicyId"` + Description string `json:"Description,omitempty"` + Tags []tag `json:"Tags,omitempty"` +} + +type firewallPolicyDetailJSON struct { + StatelessDefaultActions []string `json:"StatelessDefaultActions,omitempty"` + StatelessFragmentDefaultActions []string `json:"StatelessFragmentDefaultActions,omitempty"` +} + +type createFirewallPolicyRequest struct { + FirewallPolicyName string `json:"FirewallPolicyName"` + FirewallPolicy firewallPolicyDetailJSON `json:"FirewallPolicy"` + Description string `json:"Description"` + Tags []tag `json:"Tags"` +} + +type policyNameArnRequest struct { + FirewallPolicyName string `json:"FirewallPolicyName"` + FirewallPolicyArn string `json:"FirewallPolicyArn"` +} + +func (h *Handler) createFirewallPolicy(w http.ResponseWriter, r *http.Request) { + var req createFirewallPolicyRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + p, err := h.db.CreateFirewallPolicy(r.Context(), nfdriver.CreateFirewallPolicyConfig{ + Name: req.FirewallPolicyName, + Description: req.Description, + StatelessDefaultActions: req.FirewallPolicy.StatelessDefaultActions, + StatelessFragmentDefaultActions: req.FirewallPolicy.StatelessFragmentDefaultActions, + Tags: tagsToMap(req.Tags), + }) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{ + "UpdateToken": updateToken, + "FirewallPolicyResponse": toPolicyResponseJSON(p), + }) +} + +func (h *Handler) describeFirewallPolicy(w http.ResponseWriter, r *http.Request) { + var req policyNameArnRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + p, err := h.db.DescribeFirewallPolicy(r.Context(), req.FirewallPolicyName, req.FirewallPolicyArn) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{ + "UpdateToken": updateToken, + "FirewallPolicyResponse": toPolicyResponseJSON(p), + "FirewallPolicy": firewallPolicyDetailJSON{ + StatelessDefaultActions: p.StatelessDefaultActions, + StatelessFragmentDefaultActions: p.StatelessFragmentDefaultActions, + }, + }) +} + +func (h *Handler) deleteFirewallPolicy(w http.ResponseWriter, r *http.Request) { + var req policyNameArnRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + p, err := h.db.DeleteFirewallPolicy(r.Context(), req.FirewallPolicyName, req.FirewallPolicyArn) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{"FirewallPolicyResponse": toPolicyResponseJSON(p)}) +} + +func (h *Handler) listFirewallPolicies(w http.ResponseWriter, r *http.Request) { + items, err := h.db.ListFirewallPolicies(r.Context()) + if err != nil { + writeErr(w, err) + return + } + + metas := make([]map[string]string, 0, len(items)) + for i := range items { + metas = append(metas, map[string]string{"Name": items[i].Name, "Arn": items[i].ARN}) + } + + wire.WriteJSON(w, map[string]any{"FirewallPolicies": metas}) +} + +func toPolicyResponseJSON(p *nfdriver.FirewallPolicy) firewallPolicyResponseJSON { + return firewallPolicyResponseJSON{ + FirewallPolicyName: p.Name, FirewallPolicyArn: p.ARN, FirewallPolicyID: p.ID, + Description: p.Description, Tags: mapToTags(p.Tags), + } +} + +// ---- Rule Group ---- + +type ruleGroupResponseJSON struct { + RuleGroupName string `json:"RuleGroupName"` + RuleGroupArn string `json:"RuleGroupArn"` + RuleGroupID string `json:"RuleGroupId"` + Type string `json:"Type"` + Capacity int `json:"Capacity,omitempty"` + Description string `json:"Description,omitempty"` + Tags []tag `json:"Tags,omitempty"` +} + +type createRuleGroupRequest struct { + RuleGroupName string `json:"RuleGroupName"` + Type string `json:"Type"` + Capacity int `json:"Capacity"` + Description string `json:"Description"` + Tags []tag `json:"Tags"` +} + +type ruleGroupNameArnRequest struct { + RuleGroupName string `json:"RuleGroupName"` + RuleGroupArn string `json:"RuleGroupArn"` + Type string `json:"Type"` +} + +func (h *Handler) createRuleGroup(w http.ResponseWriter, r *http.Request) { + var req createRuleGroupRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + rg, err := h.db.CreateRuleGroup(r.Context(), nfdriver.CreateRuleGroupConfig{ + Name: req.RuleGroupName, Type: req.Type, Capacity: req.Capacity, + Description: req.Description, Tags: tagsToMap(req.Tags), + }) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{ + "UpdateToken": updateToken, + "RuleGroupResponse": toRuleGroupResponseJSON(rg), + }) +} + +func (h *Handler) describeRuleGroup(w http.ResponseWriter, r *http.Request) { + var req ruleGroupNameArnRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + rg, err := h.db.DescribeRuleGroup(r.Context(), req.RuleGroupName, req.RuleGroupArn, req.Type) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{ + "UpdateToken": updateToken, + "RuleGroupResponse": toRuleGroupResponseJSON(rg), + }) +} + +func (h *Handler) deleteRuleGroup(w http.ResponseWriter, r *http.Request) { + var req ruleGroupNameArnRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + rg, err := h.db.DeleteRuleGroup(r.Context(), req.RuleGroupName, req.RuleGroupArn, req.Type) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{"RuleGroupResponse": toRuleGroupResponseJSON(rg)}) +} + +func (h *Handler) listRuleGroups(w http.ResponseWriter, r *http.Request) { + items, err := h.db.ListRuleGroups(r.Context()) + if err != nil { + writeErr(w, err) + return + } + + metas := make([]map[string]string, 0, len(items)) + for i := range items { + metas = append(metas, map[string]string{"Name": items[i].Name, "Arn": items[i].ARN}) + } + + wire.WriteJSON(w, map[string]any{"RuleGroups": metas}) +} + +func toRuleGroupResponseJSON(rg *nfdriver.RuleGroup) ruleGroupResponseJSON { + return ruleGroupResponseJSON{ + RuleGroupName: rg.Name, RuleGroupArn: rg.ARN, RuleGroupID: rg.ID, Type: rg.Type, + Capacity: rg.Capacity, Description: rg.Description, Tags: mapToTags(rg.Tags), + } +} + +// ---- helpers ---- + +func subnetIDs(mappings []subnetMapping) []string { + if len(mappings) == 0 { + return nil + } + + out := make([]string, 0, len(mappings)) + for _, m := range mappings { + out = append(out, m.SubnetID) + } + + return out +} + +func tagsToMap(tags []tag) map[string]string { + if len(tags) == 0 { + return nil + } + + out := make(map[string]string, len(tags)) + for _, t := range tags { + out[t.Key] = t.Value + } + + return out +} + +func mapToTags(m map[string]string) []tag { + if len(m) == 0 { + return nil + } + + out := make([]tag, 0, len(m)) + for k, v := range m { + out = append(out, tag{Key: k, Value: v}) + } + + return out +} diff --git a/server/aws/networkfirewall/sdk_roundtrip_test.go b/server/aws/networkfirewall/sdk_roundtrip_test.go new file mode 100644 index 00000000..2654f5f8 --- /dev/null +++ b/server/aws/networkfirewall/sdk_roundtrip_test.go @@ -0,0 +1,166 @@ +package networkfirewall_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/networkfirewall" + nftypes "github.com/aws/aws-sdk-go-v2/service/networkfirewall/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2" + awsserver "github.com/stackshy/cloudemu/v2/server/aws" +) + +func newClient(t *testing.T) *networkfirewall.Client { + t.Helper() + + provider := cloudemu.NewAWS() + srv := awsserver.New(awsserver.Drivers{NetworkFirewall: provider.NetworkFirewall}) + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + + cfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion("us-east-1"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + require.NoError(t, err) + + return networkfirewall.NewFromConfig(cfg, func(o *networkfirewall.Options) { + o.BaseEndpoint = aws.String(ts.URL) + }) +} + +func TestSDKNetworkFirewall(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + // Rule group. + rg, err := client.CreateRuleGroup(ctx, &networkfirewall.CreateRuleGroupInput{ + RuleGroupName: aws.String("rg-1"), + Type: nftypes.RuleGroupTypeStateful, + Capacity: aws.Int32(100), + Description: aws.String("stateful rules"), + }) + require.NoError(t, err) + assert.Equal(t, "rg-1", aws.ToString(rg.RuleGroupResponse.RuleGroupName)) + assert.NotEmpty(t, aws.ToString(rg.RuleGroupResponse.RuleGroupArn)) + + // Firewall policy. + pol, err := client.CreateFirewallPolicy(ctx, &networkfirewall.CreateFirewallPolicyInput{ + FirewallPolicyName: aws.String("pol-1"), + FirewallPolicy: &nftypes.FirewallPolicy{ + StatelessDefaultActions: []string{"aws:forward_to_sfe"}, + StatelessFragmentDefaultActions: []string{"aws:forward_to_sfe"}, + }, + }) + require.NoError(t, err) + policyARN := aws.ToString(pol.FirewallPolicyResponse.FirewallPolicyArn) + require.NotEmpty(t, policyARN) + + descPol, err := client.DescribeFirewallPolicy(ctx, &networkfirewall.DescribeFirewallPolicyInput{ + FirewallPolicyName: aws.String("pol-1"), + }) + require.NoError(t, err) + assert.Equal(t, []string{"aws:forward_to_sfe"}, descPol.FirewallPolicy.StatelessDefaultActions) + + // Firewall referencing the policy. + fw, err := client.CreateFirewall(ctx, &networkfirewall.CreateFirewallInput{ + FirewallName: aws.String("fw-1"), + FirewallPolicyArn: aws.String(policyARN), + VpcId: aws.String("vpc-123"), + SubnetMappings: []nftypes.SubnetMapping{{SubnetId: aws.String("subnet-1")}}, + }) + require.NoError(t, err) + assert.Equal(t, "fw-1", aws.ToString(fw.Firewall.FirewallName)) + assert.Equal(t, policyARN, aws.ToString(fw.Firewall.FirewallPolicyArn)) + + descFw, err := client.DescribeFirewall(ctx, &networkfirewall.DescribeFirewallInput{ + FirewallName: aws.String("fw-1"), + }) + require.NoError(t, err) + assert.Equal(t, "vpc-123", aws.ToString(descFw.Firewall.VpcId)) + require.Len(t, descFw.Firewall.SubnetMappings, 1) + assert.Equal(t, "subnet-1", aws.ToString(descFw.Firewall.SubnetMappings[0].SubnetId)) + + // Depth: subnet association, delete protection, logging, tags. + _, err = client.AssociateSubnets(ctx, &networkfirewall.AssociateSubnetsInput{ + FirewallName: aws.String("fw-1"), + SubnetMappings: []nftypes.SubnetMapping{{SubnetId: aws.String("subnet-2")}}, + }) + require.NoError(t, err) + + descAfterAssoc, err := client.DescribeFirewall(ctx, &networkfirewall.DescribeFirewallInput{ + FirewallName: aws.String("fw-1"), + }) + require.NoError(t, err) + assert.Len(t, descAfterAssoc.Firewall.SubnetMappings, 2) + + _, err = client.DisassociateSubnets(ctx, &networkfirewall.DisassociateSubnetsInput{ + FirewallName: aws.String("fw-1"), SubnetIds: []string{"subnet-2"}, + }) + require.NoError(t, err) + + _, err = client.UpdateFirewallDeleteProtection(ctx, &networkfirewall.UpdateFirewallDeleteProtectionInput{ + FirewallName: aws.String("fw-1"), DeleteProtection: true, + }) + require.NoError(t, err) + + _, err = client.UpdateLoggingConfiguration(ctx, &networkfirewall.UpdateLoggingConfigurationInput{ + FirewallName: aws.String("fw-1"), + LoggingConfiguration: &nftypes.LoggingConfiguration{ + LogDestinationConfigs: []nftypes.LogDestinationConfig{{ + LogType: nftypes.LogTypeFlow, + LogDestinationType: nftypes.LogDestinationTypeCloudwatchLogs, + LogDestination: map[string]string{"logGroup": "nf-logs"}, + }}, + }, + }) + require.NoError(t, err) + + descLog, err := client.DescribeLoggingConfiguration(ctx, &networkfirewall.DescribeLoggingConfigurationInput{ + FirewallName: aws.String("fw-1"), + }) + require.NoError(t, err) + require.Len(t, descLog.LoggingConfiguration.LogDestinationConfigs, 1) + assert.Equal(t, nftypes.LogTypeFlow, descLog.LoggingConfiguration.LogDestinationConfigs[0].LogType) + + fwARN := aws.ToString(descFw.Firewall.FirewallArn) + _, err = client.TagResource(ctx, &networkfirewall.TagResourceInput{ + ResourceArn: aws.String(fwARN), + Tags: []nftypes.Tag{{Key: aws.String("env"), Value: aws.String("prod")}}, + }) + require.NoError(t, err) + + _, err = client.UntagResource(ctx, &networkfirewall.UntagResourceInput{ + ResourceArn: aws.String(fwARN), TagKeys: []string{"env"}, + }) + require.NoError(t, err) + + // Turn off delete protection so the delete below succeeds. + _, err = client.UpdateFirewallDeleteProtection(ctx, &networkfirewall.UpdateFirewallDeleteProtectionInput{ + FirewallName: aws.String("fw-1"), DeleteProtection: false, + }) + require.NoError(t, err) + + // List + delete. + list, err := client.ListFirewalls(ctx, &networkfirewall.ListFirewallsInput{}) + require.NoError(t, err) + assert.Len(t, list.Firewalls, 1) + + _, err = client.DeleteFirewall(ctx, &networkfirewall.DeleteFirewallInput{FirewallName: aws.String("fw-1")}) + require.NoError(t, err) + + _, err = client.DescribeFirewall(ctx, &networkfirewall.DescribeFirewallInput{FirewallName: aws.String("fw-1")}) + require.Error(t, err, "firewall should be gone after delete") + + // Error path: the JSON error envelope decodes into a typed SDK error. + _, err = client.DescribeFirewall(ctx, &networkfirewall.DescribeFirewallInput{FirewallName: aws.String("does-not-exist")}) + var notFound *nftypes.ResourceNotFoundException + require.ErrorAs(t, err, ¬Found, "expected ResourceNotFoundException for unknown firewall") +} diff --git a/server/aws/networking_parity_sdk_test.go b/server/aws/networking_parity_sdk_test.go new file mode 100644 index 00000000..8b6e7074 --- /dev/null +++ b/server/aws/networking_parity_sdk_test.go @@ -0,0 +1,861 @@ +package aws_test + +import ( + "context" + "errors" + "net/http/httptest" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/cloudwatch" + cwtypes "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" + "github.com/aws/aws-sdk-go-v2/service/ec2" + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + smithy "github.com/aws/smithy-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2" + awsserver "github.com/stackshy/cloudemu/v2/server/aws" +) + +// assertAPIErrorCode fails unless err is an AWS API error carrying wantCode. +func assertAPIErrorCode(t *testing.T, err error, wantCode string) { + t.Helper() + + var apiErr smithy.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected smithy.APIError with code %q, got %v", wantCode, err) + } + + if apiErr.ErrorCode() != wantCode { + t.Fatalf("error code = %q, want %q", apiErr.ErrorCode(), wantCode) + } +} + +// TestEC2NetworkingParitySDK drives the real aws-sdk-go-v2 EC2 client against +// the new AWS-only networking capabilities (transit gateway, VPN, DHCP options, +// managed prefix lists, egress-only IGW, endpoint services, Client VPN), +// proving the query-protocol XML round-trips. +func TestEC2NetworkingParitySDK(t *testing.T) { + client := newEC2Client(t) + ctx := context.Background() + + // Prerequisite VPC + subnet for attachment-style resources. + vpcOut, err := client.CreateVpc(ctx, &ec2.CreateVpcInput{CidrBlock: aws.String("10.0.0.0/16")}) + require.NoError(t, err) + vpcID := aws.ToString(vpcOut.Vpc.VpcId) + + subnetOut, err := client.CreateSubnet(ctx, &ec2.CreateSubnetInput{ + VpcId: aws.String(vpcID), CidrBlock: aws.String("10.0.1.0/24"), + }) + require.NoError(t, err) + subnetID := aws.ToString(subnetOut.Subnet.SubnetId) + + t.Run("transit gateway", func(t *testing.T) { + tgw, err := client.CreateTransitGateway(ctx, &ec2.CreateTransitGatewayInput{ + Description: aws.String("hub"), + Options: &ec2types.TransitGatewayRequestOptions{AmazonSideAsn: aws.Int64(64513)}, + }) + require.NoError(t, err) + tgwID := aws.ToString(tgw.TransitGateway.TransitGatewayId) + assert.NotEmpty(t, tgwID) + assert.EqualValues(t, 64513, aws.ToInt64(tgw.TransitGateway.Options.AmazonSideAsn)) + + desc, err := client.DescribeTransitGateways(ctx, &ec2.DescribeTransitGatewaysInput{ + TransitGatewayIds: []string{tgwID}, + }) + require.NoError(t, err) + require.Len(t, desc.TransitGateways, 1) + + att, err := client.CreateTransitGatewayVpcAttachment(ctx, &ec2.CreateTransitGatewayVpcAttachmentInput{ + TransitGatewayId: aws.String(tgwID), VpcId: aws.String(vpcID), SubnetIds: []string{subnetID}, + }) + require.NoError(t, err) + assert.Equal(t, vpcID, aws.ToString(att.TransitGatewayVpcAttachment.VpcId)) + + rt, err := client.CreateTransitGatewayRouteTable(ctx, &ec2.CreateTransitGatewayRouteTableInput{ + TransitGatewayId: aws.String(tgwID), + }) + require.NoError(t, err) + rtID := aws.ToString(rt.TransitGatewayRouteTable.TransitGatewayRouteTableId) + assert.NotEmpty(t, rtID) + attID := aws.ToString(att.TransitGatewayVpcAttachment.TransitGatewayAttachmentId) + + _, err = client.AssociateTransitGatewayRouteTable(ctx, &ec2.AssociateTransitGatewayRouteTableInput{ + TransitGatewayRouteTableId: aws.String(rtID), TransitGatewayAttachmentId: aws.String(attID), + }) + require.NoError(t, err) + + _, err = client.CreateTransitGatewayRoute(ctx, &ec2.CreateTransitGatewayRouteInput{ + TransitGatewayRouteTableId: aws.String(rtID), DestinationCidrBlock: aws.String("10.1.0.0/16"), + TransitGatewayAttachmentId: aws.String(attID), + }) + require.NoError(t, err) + + routes, err := client.SearchTransitGatewayRoutes(ctx, &ec2.SearchTransitGatewayRoutesInput{ + TransitGatewayRouteTableId: aws.String(rtID), + Filters: []ec2types.Filter{{Name: aws.String("state"), Values: []string{"active"}}}, + }) + require.NoError(t, err) + require.Len(t, routes.Routes, 1) + assert.Equal(t, "10.1.0.0/16", aws.ToString(routes.Routes[0].DestinationCidrBlock)) + + _, err = client.EnableTransitGatewayRouteTablePropagation(ctx, &ec2.EnableTransitGatewayRouteTablePropagationInput{ + TransitGatewayRouteTableId: aws.String(rtID), TransitGatewayAttachmentId: aws.String(attID), + }) + require.NoError(t, err) + + _, err = client.DeleteTransitGatewayRoute(ctx, &ec2.DeleteTransitGatewayRouteInput{ + TransitGatewayRouteTableId: aws.String(rtID), DestinationCidrBlock: aws.String("10.1.0.0/16"), + }) + require.NoError(t, err) + }) + + t.Run("vpn", func(t *testing.T) { + cgw, err := client.CreateCustomerGateway(ctx, &ec2.CreateCustomerGatewayInput{ + IpAddress: aws.String("203.0.113.10"), BgpAsn: aws.Int32(65000), Type: ec2types.GatewayTypeIpsec1, + }) + require.NoError(t, err) + cgwID := aws.ToString(cgw.CustomerGateway.CustomerGatewayId) + + vgw, err := client.CreateVpnGateway(ctx, &ec2.CreateVpnGatewayInput{Type: ec2types.GatewayTypeIpsec1}) + require.NoError(t, err) + vgwID := aws.ToString(vgw.VpnGateway.VpnGatewayId) + + _, err = client.AttachVpnGateway(ctx, &ec2.AttachVpnGatewayInput{ + VpnGatewayId: aws.String(vgwID), VpcId: aws.String(vpcID), + }) + require.NoError(t, err) + + vpn, err := client.CreateVpnConnection(ctx, &ec2.CreateVpnConnectionInput{ + CustomerGatewayId: aws.String(cgwID), VpnGatewayId: aws.String(vgwID), Type: aws.String("ipsec.1"), + }) + require.NoError(t, err) + assert.Equal(t, cgwID, aws.ToString(vpn.VpnConnection.CustomerGatewayId)) + vpnID := aws.ToString(vpn.VpnConnection.VpnConnectionId) + + _, err = client.CreateVpnConnectionRoute(ctx, &ec2.CreateVpnConnectionRouteInput{ + VpnConnectionId: aws.String(vpnID), DestinationCidrBlock: aws.String("192.168.0.0/16"), + }) + require.NoError(t, err) + + desc, err := client.DescribeVpnConnections(ctx, &ec2.DescribeVpnConnectionsInput{}) + require.NoError(t, err) + require.Len(t, desc.VpnConnections, 1) + require.Len(t, desc.VpnConnections[0].Routes, 1) + assert.Equal(t, "192.168.0.0/16", aws.ToString(desc.VpnConnections[0].Routes[0].DestinationCidrBlock)) + + _, err = client.DeleteVpnConnectionRoute(ctx, &ec2.DeleteVpnConnectionRouteInput{ + VpnConnectionId: aws.String(vpnID), DestinationCidrBlock: aws.String("192.168.0.0/16"), + }) + require.NoError(t, err) + + // Error path: modifying a nonexistent connection surfaces an SDK error + // deserialized from the query-protocol error XML. + _, err = client.ModifyVpnConnection(ctx, &ec2.ModifyVpnConnectionInput{ + VpnConnectionId: aws.String("vpn-does-not-exist"), VpnGatewayId: aws.String(vgwID), + }) + require.Error(t, err, "expected error modifying unknown vpn connection") + }) + + t.Run("dhcp options", func(t *testing.T) { + out, err := client.CreateDhcpOptions(ctx, &ec2.CreateDhcpOptionsInput{ + DhcpConfigurations: []ec2types.NewDhcpConfiguration{ + {Key: aws.String("domain-name-servers"), Values: []string{"10.0.0.2"}}, + }, + }) + require.NoError(t, err) + id := aws.ToString(out.DhcpOptions.DhcpOptionsId) + assert.NotEmpty(t, id) + + _, err = client.AssociateDhcpOptions(ctx, &ec2.AssociateDhcpOptionsInput{ + DhcpOptionsId: aws.String(id), VpcId: aws.String(vpcID), + }) + require.NoError(t, err) + }) + + t.Run("managed prefix list", func(t *testing.T) { + out, err := client.CreateManagedPrefixList(ctx, &ec2.CreateManagedPrefixListInput{ + PrefixListName: aws.String("corp"), MaxEntries: aws.Int32(10), AddressFamily: aws.String("IPv4"), + Entries: []ec2types.AddPrefixListEntry{{Cidr: aws.String("10.0.0.0/8"), Description: aws.String("corp")}}, + }) + require.NoError(t, err) + id := aws.ToString(out.PrefixList.PrefixListId) + + entries, err := client.GetManagedPrefixListEntries(ctx, &ec2.GetManagedPrefixListEntriesInput{ + PrefixListId: aws.String(id), + }) + require.NoError(t, err) + require.Len(t, entries.Entries, 1) + assert.Equal(t, "10.0.0.0/8", aws.ToString(entries.Entries[0].Cidr)) + + mod, err := client.ModifyManagedPrefixList(ctx, &ec2.ModifyManagedPrefixListInput{ + PrefixListId: aws.String(id), + AddEntries: []ec2types.AddPrefixListEntry{{Cidr: aws.String("172.16.0.0/12")}}, + RemoveEntries: []ec2types.RemovePrefixListEntry{{Cidr: aws.String("10.0.0.0/8")}}, + }) + require.NoError(t, err) + assert.NotNil(t, mod.PrefixList) + + entries2, err := client.GetManagedPrefixListEntries(ctx, &ec2.GetManagedPrefixListEntriesInput{ + PrefixListId: aws.String(id), + }) + require.NoError(t, err) + require.Len(t, entries2.Entries, 1) + assert.Equal(t, "172.16.0.0/12", aws.ToString(entries2.Entries[0].Cidr)) + }) + + t.Run("egress-only internet gateway", func(t *testing.T) { + out, err := client.CreateEgressOnlyInternetGateway(ctx, &ec2.CreateEgressOnlyInternetGatewayInput{ + VpcId: aws.String(vpcID), + }) + require.NoError(t, err) + assert.NotEmpty(t, aws.ToString(out.EgressOnlyInternetGateway.EgressOnlyInternetGatewayId)) + }) + + t.Run("vpc endpoint service", func(t *testing.T) { + out, err := client.CreateVpcEndpointServiceConfiguration(ctx, &ec2.CreateVpcEndpointServiceConfigurationInput{ + NetworkLoadBalancerArns: []string{"arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/net/x/1"}, + }) + require.NoError(t, err) + svcID := aws.ToString(out.ServiceConfiguration.ServiceId) + assert.NotEmpty(t, svcID) + + _, err = client.ModifyVpcEndpointServicePermissions(ctx, &ec2.ModifyVpcEndpointServicePermissionsInput{ + ServiceId: aws.String(svcID), + AddAllowedPrincipals: []string{"arn:aws:iam::111122223333:root"}, + }) + require.NoError(t, err) + + perms, err := client.DescribeVpcEndpointServicePermissions(ctx, &ec2.DescribeVpcEndpointServicePermissionsInput{ + ServiceId: aws.String(svcID), + }) + require.NoError(t, err) + require.Len(t, perms.AllowedPrincipals, 1) + assert.Equal(t, "arn:aws:iam::111122223333:root", aws.ToString(perms.AllowedPrincipals[0].Principal)) + }) + + t.Run("client vpn", func(t *testing.T) { + out, err := client.CreateClientVpnEndpoint(ctx, &ec2.CreateClientVpnEndpointInput{ + ClientCidrBlock: aws.String("10.100.0.0/16"), + ServerCertificateArn: aws.String("arn:aws:acm:us-east-1:000000000000:certificate/abc"), + AuthenticationOptions: []ec2types.ClientVpnAuthenticationRequest{ + {Type: ec2types.ClientVpnAuthenticationTypeCertificateAuthentication}, + }, + ConnectionLogOptions: &ec2types.ConnectionLogOptions{Enabled: aws.Bool(false)}, + }) + require.NoError(t, err) + epID := aws.ToString(out.ClientVpnEndpointId) + assert.NotEmpty(t, epID) + + assoc, err := client.AssociateClientVpnTargetNetwork(ctx, &ec2.AssociateClientVpnTargetNetworkInput{ + ClientVpnEndpointId: aws.String(epID), SubnetId: aws.String(subnetID), + }) + require.NoError(t, err) + assert.NotEmpty(t, aws.ToString(assoc.AssociationId)) + + nets, err := client.DescribeClientVpnTargetNetworks(ctx, &ec2.DescribeClientVpnTargetNetworksInput{ + ClientVpnEndpointId: aws.String(epID), + }) + require.NoError(t, err) + require.Len(t, nets.ClientVpnTargetNetworks, 1) + + _, err = client.AuthorizeClientVpnIngress(ctx, &ec2.AuthorizeClientVpnIngressInput{ + ClientVpnEndpointId: aws.String(epID), TargetNetworkCidr: aws.String("10.0.0.0/16"), + AuthorizeAllGroups: aws.Bool(true), + }) + require.NoError(t, err) + + rules, err := client.DescribeClientVpnAuthorizationRules(ctx, &ec2.DescribeClientVpnAuthorizationRulesInput{ + ClientVpnEndpointId: aws.String(epID), + }) + require.NoError(t, err) + require.Len(t, rules.AuthorizationRules, 1) + assert.Equal(t, "10.0.0.0/16", aws.ToString(rules.AuthorizationRules[0].DestinationCidr)) + + _, err = client.CreateClientVpnRoute(ctx, &ec2.CreateClientVpnRouteInput{ + ClientVpnEndpointId: aws.String(epID), DestinationCidrBlock: aws.String("0.0.0.0/0"), + TargetVpcSubnetId: aws.String(subnetID), + }) + require.NoError(t, err) + + vpnRoutes, err := client.DescribeClientVpnRoutes(ctx, &ec2.DescribeClientVpnRoutesInput{ + ClientVpnEndpointId: aws.String(epID), + }) + require.NoError(t, err) + require.Len(t, vpnRoutes.Routes, 1) + assert.Equal(t, "0.0.0.0/0", aws.ToString(vpnRoutes.Routes[0].DestinationCidr)) + }) +} + +// TestEC2IPAMParitySDK drives the real aws-sdk-go-v2 EC2 client across the +// IPAM core lifecycle (IPAM + scopes + pools + provisioned CIDRs + allocations), +// proving the query-protocol XML round-trips. +func TestEC2IPAMParitySDK(t *testing.T) { + client := newEC2Client(t) + ctx := context.Background() + + ipam, err := client.CreateIpam(ctx, &ec2.CreateIpamInput{Description: aws.String("corp")}) + require.NoError(t, err) + require.NotNil(t, ipam.Ipam) + ipamID := aws.ToString(ipam.Ipam.IpamId) + assert.NotEmpty(t, ipamID) + // Creating an IPAM implicitly creates a public + private default scope. + privScopeID := aws.ToString(ipam.Ipam.PrivateDefaultScopeId) + assert.NotEmpty(t, aws.ToString(ipam.Ipam.PublicDefaultScopeId)) + assert.NotEmpty(t, privScopeID) + assert.EqualValues(t, 2, aws.ToInt32(ipam.Ipam.ScopeCount)) + + desc, err := client.DescribeIpams(ctx, &ec2.DescribeIpamsInput{IpamIds: []string{ipamID}}) + require.NoError(t, err) + require.Len(t, desc.Ipams, 1) + + scope, err := client.CreateIpamScope(ctx, &ec2.CreateIpamScopeInput{ + IpamId: aws.String(ipamID), Description: aws.String("extra"), + }) + require.NoError(t, err) + assert.False(t, aws.ToBool(scope.IpamScope.IsDefault)) + + pool, err := client.CreateIpamPool(ctx, &ec2.CreateIpamPoolInput{ + IpamScopeId: aws.String(privScopeID), + AddressFamily: ec2types.AddressFamilyIpv4, + Locale: aws.String("us-east-1"), + }) + require.NoError(t, err) + poolID := aws.ToString(pool.IpamPool.IpamPoolId) + assert.NotEmpty(t, poolID) + assert.Equal(t, ec2types.AddressFamilyIpv4, pool.IpamPool.AddressFamily) + + // Provision supply into the pool, then read it back. + _, err = client.ProvisionIpamPoolCidr(ctx, &ec2.ProvisionIpamPoolCidrInput{ + IpamPoolId: aws.String(poolID), Cidr: aws.String("10.0.0.0/16"), + }) + require.NoError(t, err) + + cidrs, err := client.GetIpamPoolCidrs(ctx, &ec2.GetIpamPoolCidrsInput{IpamPoolId: aws.String(poolID)}) + require.NoError(t, err) + require.Len(t, cidrs.IpamPoolCidrs, 1) + assert.Equal(t, "10.0.0.0/16", aws.ToString(cidrs.IpamPoolCidrs[0].Cidr)) + + // Allocate a CIDR out of the pool, then read + release it. + alloc, err := client.AllocateIpamPoolCidr(ctx, &ec2.AllocateIpamPoolCidrInput{ + IpamPoolId: aws.String(poolID), Cidr: aws.String("10.0.1.0/24"), + }) + require.NoError(t, err) + allocID := aws.ToString(alloc.IpamPoolAllocation.IpamPoolAllocationId) + assert.NotEmpty(t, allocID) + + allocs, err := client.GetIpamPoolAllocations(ctx, &ec2.GetIpamPoolAllocationsInput{IpamPoolId: aws.String(poolID)}) + require.NoError(t, err) + require.Len(t, allocs.IpamPoolAllocations, 1) + assert.Equal(t, "10.0.1.0/24", aws.ToString(allocs.IpamPoolAllocations[0].Cidr)) + + rel, err := client.ReleaseIpamPoolAllocation(ctx, &ec2.ReleaseIpamPoolAllocationInput{ + IpamPoolId: aws.String(poolID), IpamPoolAllocationId: aws.String(allocID), Cidr: aws.String("10.0.1.0/24"), + }) + require.NoError(t, err) + assert.True(t, aws.ToBool(rel.Success)) + + // Teardown in dependency order: pool (deprovision first) → scope → ipam. + _, err = client.DeprovisionIpamPoolCidr(ctx, &ec2.DeprovisionIpamPoolCidrInput{ + IpamPoolId: aws.String(poolID), Cidr: aws.String("10.0.0.0/16"), + }) + require.NoError(t, err) + + _, err = client.DeleteIpamPool(ctx, &ec2.DeleteIpamPoolInput{IpamPoolId: aws.String(poolID)}) + require.NoError(t, err) + + _, err = client.DeleteIpamScope(ctx, &ec2.DeleteIpamScopeInput{IpamScopeId: scope.IpamScope.IpamScopeId}) + require.NoError(t, err) + + _, err = client.DeleteIpam(ctx, &ec2.DeleteIpamInput{IpamId: aws.String(ipamID)}) + require.NoError(t, err) + + // Error path: describing a deleted IPAM by id returns an empty set. + after, err := client.DescribeIpams(ctx, &ec2.DescribeIpamsInput{IpamIds: []string{ipamID}}) + require.NoError(t, err) + assert.Empty(t, after.Ipams) +} + +// TestEC2IPAMFullSDK drives the real EC2 client across the full IPAM surface +// beyond the core lifecycle: resource CIDRs + history, resource discovery + +// discovered getters, BYOASN + BYOIP, prefix-list resolver + targets, +// verification tokens, and policy + org-admin — proving the query wire. +func TestEC2IPAMFullSDK(t *testing.T) { + client := newEC2Client(t) + ctx := context.Background() + + // Prerequisite VPC + subnet so resource CIDRs / discovery / utilization + // have something to report. + vpcOut, err := client.CreateVpc(ctx, &ec2.CreateVpcInput{CidrBlock: aws.String("10.0.0.0/16")}) + require.NoError(t, err) + vpcID := aws.ToString(vpcOut.Vpc.VpcId) + _, err = client.CreateSubnet(ctx, &ec2.CreateSubnetInput{VpcId: aws.String(vpcID), CidrBlock: aws.String("10.0.1.0/24")}) + require.NoError(t, err) + + ipam, err := client.CreateIpam(ctx, &ec2.CreateIpamInput{}) + require.NoError(t, err) + ipamID := aws.ToString(ipam.Ipam.IpamId) + privScopeID := aws.ToString(ipam.Ipam.PrivateDefaultScopeId) + // A default resource discovery + association is created with the IPAM. + rdID := aws.ToString(ipam.Ipam.DefaultResourceDiscoveryId) + require.NotEmpty(t, rdID) + + t.Run("resource cidrs + history", func(t *testing.T) { + rc, err := client.GetIpamResourceCidrs(ctx, &ec2.GetIpamResourceCidrsInput{IpamScopeId: aws.String(privScopeID)}) + require.NoError(t, err) + require.NotEmpty(t, rc.IpamResourceCidrs) + + hist, err := client.GetIpamAddressHistory(ctx, &ec2.GetIpamAddressHistoryInput{ + Cidr: aws.String("10.0.0.0/16"), IpamScopeId: aws.String(privScopeID), + }) + require.NoError(t, err) + require.NotEmpty(t, hist.HistoryRecords) + }) + + t.Run("resource discovery", func(t *testing.T) { + descRD, err := client.DescribeIpamResourceDiscoveries(ctx, &ec2.DescribeIpamResourceDiscoveriesInput{ + IpamResourceDiscoveryIds: []string{rdID}, + }) + require.NoError(t, err) + require.Len(t, descRD.IpamResourceDiscoveries, 1) + assert.True(t, aws.ToBool(descRD.IpamResourceDiscoveries[0].IsDefault)) + + accts, err := client.GetIpamDiscoveredAccounts(ctx, &ec2.GetIpamDiscoveredAccountsInput{ + IpamResourceDiscoveryId: aws.String(rdID), DiscoveryRegion: aws.String("us-east-1"), + }) + require.NoError(t, err) + require.Len(t, accts.IpamDiscoveredAccounts, 1) + + cidrs, err := client.GetIpamDiscoveredResourceCidrs(ctx, &ec2.GetIpamDiscoveredResourceCidrsInput{ + IpamResourceDiscoveryId: aws.String(rdID), ResourceRegion: aws.String("us-east-1"), + }) + require.NoError(t, err) + require.NotEmpty(t, cidrs.IpamDiscoveredResourceCidrs) + }) + + t.Run("byoip + byoasn", func(t *testing.T) { + _, err := client.ProvisionByoipCidr(ctx, &ec2.ProvisionByoipCidrInput{Cidr: aws.String("203.0.113.0/24")}) + require.NoError(t, err) + + byoip, err := client.DescribeByoipCidrs(ctx, &ec2.DescribeByoipCidrsInput{MaxResults: aws.Int32(10)}) + require.NoError(t, err) + require.Len(t, byoip.ByoipCidrs, 1) + + _, err = client.ProvisionIpamByoasn(ctx, &ec2.ProvisionIpamByoasnInput{ + IpamId: aws.String(ipamID), Asn: aws.String("64512"), + AsnAuthorizationContext: &ec2types.AsnAuthorizationContext{ + Message: aws.String("msg"), Signature: aws.String("sig"), + }, + }) + require.NoError(t, err) + + asns, err := client.DescribeIpamByoasn(ctx, &ec2.DescribeIpamByoasnInput{}) + require.NoError(t, err) + require.Len(t, asns.Byoasns, 1) + }) + + t.Run("prefix list resolver + token", func(t *testing.T) { + res, err := client.CreateIpamPrefixListResolver(ctx, &ec2.CreateIpamPrefixListResolverInput{ + IpamId: aws.String(ipamID), AddressFamily: ec2types.AddressFamilyIpv4, + }) + require.NoError(t, err) + resID := aws.ToString(res.IpamPrefixListResolver.IpamPrefixListResolverId) + assert.NotEmpty(t, resID) + + descRes, err := client.DescribeIpamPrefixListResolvers(ctx, &ec2.DescribeIpamPrefixListResolversInput{ + IpamPrefixListResolverIds: []string{resID}, + }) + require.NoError(t, err) + require.Len(t, descRes.IpamPrefixListResolvers, 1) + + tok, err := client.CreateIpamExternalResourceVerificationToken(ctx, &ec2.CreateIpamExternalResourceVerificationTokenInput{ + IpamId: aws.String(ipamID), + }) + require.NoError(t, err) + assert.NotEmpty(t, aws.ToString(tok.IpamExternalResourceVerificationToken.IpamExternalResourceVerificationTokenId)) + }) + + t.Run("policy + org admin", func(t *testing.T) { + pol, err := client.CreateIpamPolicy(ctx, &ec2.CreateIpamPolicyInput{IpamId: aws.String(ipamID)}) + require.NoError(t, err) + polID := aws.ToString(pol.IpamPolicy.IpamPolicyId) + assert.NotEmpty(t, polID) + + _, err = client.EnableIpamPolicy(ctx, &ec2.EnableIpamPolicyInput{IpamPolicyId: aws.String(polID)}) + require.NoError(t, err) + + enabled, err := client.GetEnabledIpamPolicy(ctx, &ec2.GetEnabledIpamPolicyInput{}) + require.NoError(t, err) + assert.True(t, aws.ToBool(enabled.IpamPolicyEnabled)) + + // Modify allocation rules — the response must carry a non-nil + // IpamPolicyDocument (not a dropped true>), and Get must return + // the rule's sourceIpamPoolId + ipamPolicyId (not an empty document). + mod, err := client.ModifyIpamPolicyAllocationRules(ctx, &ec2.ModifyIpamPolicyAllocationRulesInput{ + IpamPolicyId: aws.String(polID), + Locale: aws.String("us-east-1"), + ResourceType: ec2types.IpamPolicyResourceTypeEip, + AllocationRules: []ec2types.IpamPolicyAllocationRuleRequest{ + {SourceIpamPoolId: aws.String("ipam-pool-abc123")}, + }, + }) + require.NoError(t, err) + require.NotNil(t, mod.IpamPolicyDocument, "ModifyIpamPolicyAllocationRules dropped the document") + require.Len(t, mod.IpamPolicyDocument.AllocationRules, 1) + assert.Equal(t, "ipam-pool-abc123", aws.ToString(mod.IpamPolicyDocument.AllocationRules[0].SourceIpamPoolId)) + + got, err := client.GetIpamPolicyAllocationRules(ctx, &ec2.GetIpamPolicyAllocationRulesInput{ + IpamPolicyId: aws.String(polID), + }) + require.NoError(t, err) + require.Len(t, got.IpamPolicyDocuments, 1) + assert.Equal(t, polID, aws.ToString(got.IpamPolicyDocuments[0].IpamPolicyId)) + require.Len(t, got.IpamPolicyDocuments[0].AllocationRules, 1) + assert.Equal(t, "ipam-pool-abc123", + aws.ToString(got.IpamPolicyDocuments[0].AllocationRules[0].SourceIpamPoolId)) + + admin, err := client.EnableIpamOrganizationAdminAccount(ctx, &ec2.EnableIpamOrganizationAdminAccountInput{ + DelegatedAdminAccountId: aws.String("111122223333"), + }) + require.NoError(t, err) + assert.True(t, aws.ToBool(admin.Success)) + }) +} + +// TestIPAMMetricsSDK proves the derived AWS/IPAM metrics surface through the +// real CloudWatch SDK (ListMetrics + GetMetricStatistics), wired via the +// VPC driver's optional IPAMMetrics capability. +func TestIPAMMetricsSDK(t *testing.T) { + provider := cloudemu.NewAWS() + srv := awsserver.New(awsserver.Drivers{EC2: provider.EC2, VPC: provider.VPC, CloudWatch: provider.CloudWatch}) + ts := httptest.NewServer(srv) + defer ts.Close() + + cfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion("us-east-1"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("t", "t", ""))) + require.NoError(t, err) + + ec2c := ec2.NewFromConfig(cfg, func(o *ec2.Options) { o.BaseEndpoint = aws.String(ts.URL) }) + cw := cloudwatch.NewFromConfig(cfg, func(o *cloudwatch.Options) { o.BaseEndpoint = aws.String(ts.URL) }) + ctx := context.Background() + + // Build IPAM state: a VPC with a subnet (drives VpcIPUsage) and an IPAM. + vpcOut, err := ec2c.CreateVpc(ctx, &ec2.CreateVpcInput{CidrBlock: aws.String("10.0.0.0/16")}) + require.NoError(t, err) + _, err = ec2c.CreateSubnet(ctx, &ec2.CreateSubnetInput{ + VpcId: vpcOut.Vpc.VpcId, CidrBlock: aws.String("10.0.0.0/24"), + }) + require.NoError(t, err) + _, err = ec2c.CreateIpam(ctx, &ec2.CreateIpamInput{}) + require.NoError(t, err) + + // ListMetrics on AWS/IPAM returns the derived metric set. + list, err := cw.ListMetrics(ctx, &cloudwatch.ListMetricsInput{Namespace: aws.String("AWS/IPAM")}) + require.NoError(t, err) + names := map[string]bool{} + for _, m := range list.Metrics { + names[aws.ToString(m.MetricName)] = true + } + assert.True(t, names["TotalActiveIpCount"], "expected TotalActiveIpCount metric") + assert.True(t, names["VpcIPUsage"], "expected VpcIPUsage metric") + + // Regression (#318 review): a real metric plus an empty-namespace + // "list all" call must return BOTH the real namespace and AWS/IPAM — the + // IPAM shortcut must not drop every non-IPAM metric. + _, err = cw.PutMetricData(ctx, &cloudwatch.PutMetricDataInput{ + Namespace: aws.String("MyApp"), + MetricData: []cwtypes.MetricDatum{{MetricName: aws.String("RequestCount"), Value: aws.Float64(1)}}, + }) + require.NoError(t, err) + + all, err := cw.ListMetrics(ctx, &cloudwatch.ListMetricsInput{}) + require.NoError(t, err) + + namespaces := map[string]bool{} + for _, m := range all.Metrics { + namespaces[aws.ToString(m.Namespace)] = true + } + assert.True(t, namespaces["MyApp"], "empty-namespace ListMetrics dropped the real MyApp metric") + assert.True(t, namespaces["AWS/IPAM"], "empty-namespace ListMetrics dropped the AWS/IPAM metrics") + + // GetMetricStatistics for VpcIPUsage returns the computed utilization (a + // /24 subnet in a /16 VPC = 256/65536 = ~0.39%). + vpcID := aws.ToString(vpcOut.Vpc.VpcId) + end := time.Now().UTC() + stat, err := cw.GetMetricStatistics(ctx, &cloudwatch.GetMetricStatisticsInput{ + Namespace: aws.String("AWS/IPAM"), + MetricName: aws.String("VpcIPUsage"), + Dimensions: []cwtypes.Dimension{ + {Name: aws.String("VpcID"), Value: aws.String(vpcID)}, + {Name: aws.String("AddressFamily"), Value: aws.String("IPv4")}, + {Name: aws.String("Region"), Value: aws.String("us-east-1")}, + }, + StartTime: aws.Time(end.Add(-time.Hour)), + EndTime: aws.Time(end), + Period: aws.Int32(60), + Statistics: []cwtypes.Statistic{cwtypes.StatisticAverage}, + }) + require.NoError(t, err) + require.Len(t, stat.Datapoints, 1) + assert.InDelta(t, 0.39, aws.ToFloat64(stat.Datapoints[0].Average), 0.05) +} + +// TestEC2StageBNetworkingParitySDK drives the real aws-sdk-go-v2 EC2 client +// against the Stage B EC2-family capabilities (Traffic Mirroring, Network +// Insights / Access Analyzer, VPC Block Public Access), proving the +// query-protocol XML round-trips through the SDK deserializers. +func TestEC2StageBNetworkingParitySDK(t *testing.T) { + client := newEC2Client(t) + ctx := context.Background() + + vpcOut, err := client.CreateVpc(ctx, &ec2.CreateVpcInput{CidrBlock: aws.String("10.0.0.0/16")}) + require.NoError(t, err) + vpcID := aws.ToString(vpcOut.Vpc.VpcId) + + subnetOut, err := client.CreateSubnet(ctx, &ec2.CreateSubnetInput{ + VpcId: aws.String(vpcID), CidrBlock: aws.String("10.0.1.0/24"), + }) + require.NoError(t, err) + subnetID := aws.ToString(subnetOut.Subnet.SubnetId) + + t.Run("traffic mirroring", func(t *testing.T) { + target, err := client.CreateTrafficMirrorTarget(ctx, &ec2.CreateTrafficMirrorTargetInput{ + NetworkInterfaceId: aws.String("eni-abc"), Description: aws.String("t"), + }) + require.NoError(t, err) + targetID := aws.ToString(target.TrafficMirrorTarget.TrafficMirrorTargetId) + assert.NotEmpty(t, targetID) + assert.Equal(t, ec2types.TrafficMirrorTargetTypeNetworkInterface, target.TrafficMirrorTarget.Type) + + filter, err := client.CreateTrafficMirrorFilter(ctx, &ec2.CreateTrafficMirrorFilterInput{ + Description: aws.String("f"), + }) + require.NoError(t, err) + filterID := aws.ToString(filter.TrafficMirrorFilter.TrafficMirrorFilterId) + assert.NotEmpty(t, filterID) + + rule, err := client.CreateTrafficMirrorFilterRule(ctx, &ec2.CreateTrafficMirrorFilterRuleInput{ + TrafficMirrorFilterId: aws.String(filterID), + TrafficDirection: ec2types.TrafficDirectionIngress, + RuleNumber: aws.Int32(100), + RuleAction: ec2types.TrafficMirrorRuleActionAccept, + SourceCidrBlock: aws.String("0.0.0.0/0"), + DestinationCidrBlock: aws.String("10.0.0.0/16"), + Protocol: aws.Int32(6), + DestinationPortRange: &ec2types.TrafficMirrorPortRangeRequest{FromPort: aws.Int32(443), ToPort: aws.Int32(443)}, + }) + require.NoError(t, err) + ruleID := aws.ToString(rule.TrafficMirrorFilterRule.TrafficMirrorFilterRuleId) + assert.NotEmpty(t, ruleID) + assert.EqualValues(t, 443, aws.ToInt32(rule.TrafficMirrorFilterRule.DestinationPortRange.FromPort)) + + rules, err := client.DescribeTrafficMirrorFilterRules(ctx, &ec2.DescribeTrafficMirrorFilterRulesInput{ + TrafficMirrorFilterId: aws.String(filterID), + }) + require.NoError(t, err) + require.Len(t, rules.TrafficMirrorFilterRules, 1) + + // No-filter Describe of targets/filters round-trips the list shape. + listTargets, err := client.DescribeTrafficMirrorTargets(ctx, &ec2.DescribeTrafficMirrorTargetsInput{}) + require.NoError(t, err) + require.Len(t, listTargets.TrafficMirrorTargets, 1) + + listFilters, err := client.DescribeTrafficMirrorFilters(ctx, &ec2.DescribeTrafficMirrorFiltersInput{}) + require.NoError(t, err) + require.Len(t, listFilters.TrafficMirrorFilters, 1) + + session, err := client.CreateTrafficMirrorSession(ctx, &ec2.CreateTrafficMirrorSessionInput{ + NetworkInterfaceId: aws.String("eni-src"), + TrafficMirrorTargetId: aws.String(targetID), + TrafficMirrorFilterId: aws.String(filterID), + SessionNumber: aws.Int32(1), + }) + require.NoError(t, err) + sessionID := aws.ToString(session.TrafficMirrorSession.TrafficMirrorSessionId) + assert.NotEmpty(t, sessionID) + assert.EqualValues(t, 1, aws.ToInt32(session.TrafficMirrorSession.VirtualNetworkId)) + + sessions, err := client.DescribeTrafficMirrorSessions(ctx, &ec2.DescribeTrafficMirrorSessionsInput{ + TrafficMirrorSessionIds: []string{sessionID}, + }) + require.NoError(t, err) + require.Len(t, sessions.TrafficMirrorSessions, 1) + + // A live session blocks deleting its target/filter (EC2 DependencyViolation). + _, err = client.DeleteTrafficMirrorTarget(ctx, &ec2.DeleteTrafficMirrorTargetInput{ + TrafficMirrorTargetId: aws.String(targetID), + }) + assertAPIErrorCode(t, err, "DependencyViolation") + + // A missing filter reports its own resource-specific NotFound code. + _, err = client.DeleteTrafficMirrorFilter(ctx, &ec2.DeleteTrafficMirrorFilterInput{ + TrafficMirrorFilterId: aws.String("tmf-does-not-exist"), + }) + assertAPIErrorCode(t, err, "InvalidTrafficMirrorFilterId.NotFound") + + _, err = client.DeleteTrafficMirrorSession(ctx, &ec2.DeleteTrafficMirrorSessionInput{ + TrafficMirrorSessionId: aws.String(sessionID), + }) + require.NoError(t, err) + _, err = client.DeleteTrafficMirrorFilterRule(ctx, &ec2.DeleteTrafficMirrorFilterRuleInput{ + TrafficMirrorFilterRuleId: aws.String(ruleID), + }) + require.NoError(t, err) + _, err = client.DeleteTrafficMirrorFilter(ctx, &ec2.DeleteTrafficMirrorFilterInput{ + TrafficMirrorFilterId: aws.String(filterID), + }) + require.NoError(t, err) + _, err = client.DeleteTrafficMirrorTarget(ctx, &ec2.DeleteTrafficMirrorTargetInput{ + TrafficMirrorTargetId: aws.String(targetID), + }) + require.NoError(t, err) + }) + + t.Run("reachability analyzer", func(t *testing.T) { + path, err := client.CreateNetworkInsightsPath(ctx, &ec2.CreateNetworkInsightsPathInput{ + Protocol: ec2types.ProtocolTcp, Source: aws.String("igw-1"), + Destination: aws.String("eni-1"), DestinationPort: aws.Int32(443), + }) + require.NoError(t, err) + pathID := aws.ToString(path.NetworkInsightsPath.NetworkInsightsPathId) + assert.NotEmpty(t, pathID) + assert.NotEmpty(t, aws.ToString(path.NetworkInsightsPath.NetworkInsightsPathArn)) + + analysis, err := client.StartNetworkInsightsAnalysis(ctx, &ec2.StartNetworkInsightsAnalysisInput{ + NetworkInsightsPathId: aws.String(pathID), + }) + require.NoError(t, err) + assert.Equal(t, ec2types.AnalysisStatusSucceeded, analysis.NetworkInsightsAnalysis.Status) + assert.True(t, aws.ToBool(analysis.NetworkInsightsAnalysis.NetworkPathFound)) + + analyses, err := client.DescribeNetworkInsightsAnalyses(ctx, &ec2.DescribeNetworkInsightsAnalysesInput{ + NetworkInsightsPathId: aws.String(pathID), + }) + require.NoError(t, err) + require.Len(t, analyses.NetworkInsightsAnalyses, 1) + + paths, err := client.DescribeNetworkInsightsPaths(ctx, &ec2.DescribeNetworkInsightsPathsInput{}) + require.NoError(t, err) + require.Len(t, paths.NetworkInsightsPaths, 1) + + _, err = client.DeleteNetworkInsightsPath(ctx, &ec2.DeleteNetworkInsightsPathInput{ + NetworkInsightsPathId: aws.String(pathID), + }) + require.NoError(t, err) + + // A missing path reports its resource-specific NotFound code. + _, err = client.DeleteNetworkInsightsPath(ctx, &ec2.DeleteNetworkInsightsPathInput{ + NetworkInsightsPathId: aws.String("nip-does-not-exist"), + }) + assertAPIErrorCode(t, err, "InvalidNetworkInsightsPathId.NotFound") + }) + + t.Run("access analyzer", func(t *testing.T) { + scope, err := client.CreateNetworkInsightsAccessScope(ctx, &ec2.CreateNetworkInsightsAccessScopeInput{ + MatchPaths: []ec2types.AccessScopePathRequest{{ + Source: &ec2types.PathStatementRequest{ + ResourceStatement: &ec2types.ResourceStatementRequest{ + ResourceTypes: []string{"AWS::EC2::InternetGateway"}, + }, + }, + }}, + }) + require.NoError(t, err) + scopeID := aws.ToString(scope.NetworkInsightsAccessScope.NetworkInsightsAccessScopeId) + assert.NotEmpty(t, scopeID) + require.Len(t, scope.NetworkInsightsAccessScopeContent.MatchPaths, 1) + + content, err := client.GetNetworkInsightsAccessScopeContent(ctx, &ec2.GetNetworkInsightsAccessScopeContentInput{ + NetworkInsightsAccessScopeId: aws.String(scopeID), + }) + require.NoError(t, err) + require.Len(t, content.NetworkInsightsAccessScopeContent.MatchPaths, 1) + assert.Equal(t, "AWS::EC2::InternetGateway", + content.NetworkInsightsAccessScopeContent.MatchPaths[0].Source.ResourceStatement.ResourceTypes[0]) + + analysis, err := client.StartNetworkInsightsAccessScopeAnalysis(ctx, &ec2.StartNetworkInsightsAccessScopeAnalysisInput{ + NetworkInsightsAccessScopeId: aws.String(scopeID), + }) + require.NoError(t, err) + analysisID := aws.ToString(analysis.NetworkInsightsAccessScopeAnalysis.NetworkInsightsAccessScopeAnalysisId) + assert.Equal(t, ec2types.AnalysisStatusSucceeded, analysis.NetworkInsightsAccessScopeAnalysis.Status) + + scopes, err := client.DescribeNetworkInsightsAccessScopes(ctx, + &ec2.DescribeNetworkInsightsAccessScopesInput{}) + require.NoError(t, err) + require.Len(t, scopes.NetworkInsightsAccessScopes, 1) + + findings, err := client.GetNetworkInsightsAccessScopeAnalysisFindings(ctx, + &ec2.GetNetworkInsightsAccessScopeAnalysisFindingsInput{ + NetworkInsightsAccessScopeAnalysisId: aws.String(analysisID), + }) + require.NoError(t, err) + assert.Equal(t, ec2types.AnalysisStatusSucceeded, findings.AnalysisStatus) + // The findings member deserializes as the AccessScopeAnalysisFinding + // object shape (empty here — the mock reports no findings), proving the + // wire type is correct rather than a string list. + assert.Empty(t, findings.AnalysisFindings) + + _, err = client.DeleteNetworkInsightsAccessScope(ctx, &ec2.DeleteNetworkInsightsAccessScopeInput{ + NetworkInsightsAccessScopeId: aws.String(scopeID), + }) + require.NoError(t, err) + }) + + t.Run("vpc block public access", func(t *testing.T) { + opts, err := client.DescribeVpcBlockPublicAccessOptions(ctx, &ec2.DescribeVpcBlockPublicAccessOptionsInput{}) + require.NoError(t, err) + assert.Equal(t, ec2types.InternetGatewayBlockModeOff, opts.VpcBlockPublicAccessOptions.InternetGatewayBlockMode) + + mod, err := client.ModifyVpcBlockPublicAccessOptions(ctx, &ec2.ModifyVpcBlockPublicAccessOptionsInput{ + InternetGatewayBlockMode: ec2types.InternetGatewayBlockModeBlockBidirectional, + }) + require.NoError(t, err) + assert.Equal(t, ec2types.InternetGatewayBlockModeBlockBidirectional, + mod.VpcBlockPublicAccessOptions.InternetGatewayBlockMode) + + excl, err := client.CreateVpcBlockPublicAccessExclusion(ctx, &ec2.CreateVpcBlockPublicAccessExclusionInput{ + SubnetId: aws.String(subnetID), + InternetGatewayExclusionMode: ec2types.InternetGatewayExclusionModeAllowBidirectional, + }) + require.NoError(t, err) + exclID := aws.ToString(excl.VpcBlockPublicAccessExclusion.ExclusionId) + assert.NotEmpty(t, exclID) + + modExcl, err := client.ModifyVpcBlockPublicAccessExclusion(ctx, &ec2.ModifyVpcBlockPublicAccessExclusionInput{ + ExclusionId: aws.String(exclID), + InternetGatewayExclusionMode: ec2types.InternetGatewayExclusionModeAllowEgress, + }) + require.NoError(t, err) + assert.Equal(t, ec2types.InternetGatewayExclusionModeAllowEgress, + modExcl.VpcBlockPublicAccessExclusion.InternetGatewayExclusionMode) + + list, err := client.DescribeVpcBlockPublicAccessExclusions(ctx, &ec2.DescribeVpcBlockPublicAccessExclusionsInput{}) + require.NoError(t, err) + require.Len(t, list.VpcBlockPublicAccessExclusions, 1) + + _, err = client.DeleteVpcBlockPublicAccessExclusion(ctx, &ec2.DeleteVpcBlockPublicAccessExclusionInput{ + ExclusionId: aws.String(exclID), + }) + require.NoError(t, err) + + // A missing exclusion reports its resource-specific NotFound code. + _, err = client.DeleteVpcBlockPublicAccessExclusion(ctx, &ec2.DeleteVpcBlockPublicAccessExclusionInput{ + ExclusionId: aws.String("vpcbpa-exclude-nope"), + }) + assertAPIErrorCode(t, err, "InvalidVpcBlockPublicAccessExclusionId.NotFound") + + // Creating an exclusion for a nonexistent subnet keys on the subnet code. + _, err = client.CreateVpcBlockPublicAccessExclusion(ctx, &ec2.CreateVpcBlockPublicAccessExclusionInput{ + SubnetId: aws.String("subnet-nope"), + InternetGatewayExclusionMode: ec2types.InternetGatewayExclusionModeAllowEgress, + }) + assertAPIErrorCode(t, err, "InvalidSubnetID.NotFound") + }) +} diff --git a/server/aws/rds/handler.go b/server/aws/rds/handler.go index 413ad831..a96a954b 100644 --- a/server/aws/rds/handler.go +++ b/server/aws/rds/handler.go @@ -143,9 +143,47 @@ func (*Handler) Matches(r *http.Request) bool { return false } - _, ok := rdsActions[r.Form.Get("Action")] + action := r.Form.Get("Action") + if _, ok := rdsActions[action]; !ok { + return false + } + + // AddTagsToResource/RemoveTagsFromResource/ListTagsForResource are generic + // tag verbs RDS shares with other query-protocol services (e.g. ElastiCache + // on the same wire). RDS registers before them, so claim these only when + // the SigV4 credential scope names "rds"; otherwise let them fall through + // to the owning handler. + if _, ambiguous := rdsAmbiguousTagActions[action]; ambiguous { + return sigV4ScopeService(r.Header.Get("Authorization")) == "rds" + } + + return true +} + +// rdsAmbiguousTagActions are the tag verbs RDS shares with other +// query-protocol services on the same wire. +var rdsAmbiguousTagActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup table + "AddTagsToResource": {}, + "RemoveTagsFromResource": {}, + "ListTagsForResource": {}, +} + +// sigV4ScopeService extracts the service from a SigV4 Authorization credential +// scope: "Credential=AKID/20260101/us-east-1//aws4_request". +func sigV4ScopeService(auth string) string { + i := strings.Index(auth, "Credential=") + if i < 0 { + return "" + } + + parts := strings.Split(auth[i+len("Credential="):], "/") + + const serviceField = 3 + if len(parts) <= serviceField { + return "" + } - return ok + return parts[serviceField] } // ServeHTTP dispatches on Action. The form has already been parsed by Matches. diff --git a/server/aws/redshift/handler.go b/server/aws/redshift/handler.go index 806ff92c..833e1812 100644 --- a/server/aws/redshift/handler.go +++ b/server/aws/redshift/handler.go @@ -12,10 +12,12 @@ package redshift import ( + "context" "net/http" "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" + redshiftprovider "github.com/stackshy/cloudemu/v2/providers/aws/redshift" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" rdbdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" ) @@ -31,15 +33,34 @@ const ( // redshiftActions is the set of Action values this handler recognizes. Matches // uses it to decide whether to claim a request. var redshiftActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup table - "CreateCluster": {}, - "DescribeClusters": {}, - "ModifyCluster": {}, - "DeleteCluster": {}, - "RebootCluster": {}, - "CreateClusterSnapshot": {}, - "DescribeClusterSnapshots": {}, - "DeleteClusterSnapshot": {}, - "RestoreFromClusterSnapshot": {}, + "CreateCluster": {}, + "DescribeClusters": {}, + "ModifyCluster": {}, + "DeleteCluster": {}, + "RebootCluster": {}, + "CreateClusterSnapshot": {}, + "DescribeClusterSnapshots": {}, + "DeleteClusterSnapshot": {}, + "RestoreFromClusterSnapshot": {}, + "CreateClusterParameterGroup": {}, + "CreateClusterSubnetGroup": {}, + "CreateTags": {}, + "DeleteTags": {}, + "DescribeTags": {}, +} + +// clusterGroupManager is the AWS-specific parameter/subnet-group surface, not +// part of the shared relationaldb driver; the handler type-asserts for it. +type clusterGroupManager interface { + CreateClusterParameterGroup(ctx context.Context, name, family, description string) (*redshiftprovider.ParameterGroup, error) + CreateClusterSubnetGroup(ctx context.Context, name, description string, subnetIDs []string) (*redshiftprovider.SubnetGroup, error) +} + +// resourceTagger is the AWS-specific Redshift tagging surface. +type resourceTagger interface { + CreateTags(ctx context.Context, resourceName string, tags map[string]string) error + DeleteTags(ctx context.Context, resourceName string, keys []string) error + DescribeTags(ctx context.Context, resourceName string) (map[string]string, error) } // Handler serves Redshift query-protocol requests. @@ -74,9 +95,46 @@ func (*Handler) Matches(r *http.Request) bool { return false } - _, ok := redshiftActions[r.Form.Get("Action")] + action := r.Form.Get("Action") + if _, ok := redshiftActions[action]; !ok { + return false + } + + // CreateTags/DeleteTags/DescribeTags are generic tag verbs shared with EC2 + // and ELBv2 on the same query protocol. Redshift registers before both, so + // claim these only when the SigV4 credential scope names "redshift"; + // otherwise let them fall through to the owning handler. + if _, ambiguous := ambiguousTagActions[action]; ambiguous { + return sigV4ScopeService(r.Header.Get("Authorization")) == "redshift" + } + + return true +} + +// ambiguousTagActions are the tag verbs Redshift shares with other +// query-protocol services (EC2 CreateTags/DeleteTags, ELBv2 DescribeTags). +var ambiguousTagActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup table + "CreateTags": {}, + "DeleteTags": {}, + "DescribeTags": {}, +} + +// sigV4ScopeService extracts the service from a SigV4 Authorization credential +// scope: "Credential=AKID/20260101/us-east-1//aws4_request". +func sigV4ScopeService(auth string) string { + i := strings.Index(auth, "Credential=") + if i < 0 { + return "" + } + + parts := strings.Split(auth[i+len("Credential="):], "/") + + const serviceField = 3 + if len(parts) <= serviceField { + return "" + } - return ok + return parts[serviceField] } // ServeHTTP dispatches on Action. The form has already been parsed by Matches. @@ -102,6 +160,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.deleteClusterSnapshot(w, r) case "RestoreFromClusterSnapshot": h.restoreFromClusterSnapshot(w, r) + case "CreateClusterParameterGroup": + h.createClusterParameterGroup(w, r) + case "CreateClusterSubnetGroup": + h.createClusterSubnetGroup(w, r) + case "CreateTags": + h.createTags(w, r) + case "DeleteTags": + h.deleteTags(w, r) + case "DescribeTags": + h.describeTags(w, r) default: awsquery.WriteXMLError(w, http.StatusBadRequest, "InvalidAction", "unknown Redshift action: "+action) diff --git a/server/aws/redshift/parametergroup.go b/server/aws/redshift/parametergroup.go new file mode 100644 index 00000000..d47aaefe --- /dev/null +++ b/server/aws/redshift/parametergroup.go @@ -0,0 +1,92 @@ +package redshift + +import ( + "encoding/xml" + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" +) + +type clusterParameterGroupXML struct { + ParameterGroupName string `xml:"ParameterGroupName"` + ParameterGroupFamily string `xml:"ParameterGroupFamily"` + Description string `xml:"Description"` +} + +type createClusterParameterGroupResponse struct { + XMLName xml.Name `xml:"CreateClusterParameterGroupResponse"` + Xmlns string `xml:"xmlns,attr"` + Group clusterParameterGroupXML `xml:"CreateClusterParameterGroupResult>ClusterParameterGroup"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type clusterSubnetGroupXML struct { + ClusterSubnetGroupName string `xml:"ClusterSubnetGroupName"` + Description string `xml:"Description"` + SubnetGroupStatus string `xml:"SubnetGroupStatus"` +} + +type createClusterSubnetGroupResponse struct { + XMLName xml.Name `xml:"CreateClusterSubnetGroupResponse"` + Xmlns string `xml:"xmlns,attr"` + Group clusterSubnetGroupXML `xml:"CreateClusterSubnetGroupResult>ClusterSubnetGroup"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +func (h *Handler) clusterGroups() (clusterGroupManager, bool) { + m, ok := h.db.(clusterGroupManager) + + return m, ok +} + +func (h *Handler) createClusterParameterGroup(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.clusterGroups() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "parameter groups not supported")) + return + } + + pg, err := mgr.CreateClusterParameterGroup(r.Context(), + r.Form.Get("ParameterGroupName"), r.Form.Get("ParameterGroupFamily"), r.Form.Get("Description")) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, createClusterParameterGroupResponse{ + Xmlns: Namespace, + Group: clusterParameterGroupXML{ + ParameterGroupName: pg.Name, + ParameterGroupFamily: pg.Family, + Description: pg.Description, + }, + Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) createClusterSubnetGroup(w http.ResponseWriter, r *http.Request) { + mgr, ok := h.clusterGroups() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "subnet groups not supported")) + return + } + + sg, err := mgr.CreateClusterSubnetGroup(r.Context(), + r.Form.Get("ClusterSubnetGroupName"), r.Form.Get("Description"), + awsquery.ListStrings(r.Form, "SubnetIds.SubnetIdentifier")) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, createClusterSubnetGroupResponse{ + Xmlns: Namespace, + Group: clusterSubnetGroupXML{ + ClusterSubnetGroupName: sg.Name, + Description: sg.Description, + SubnetGroupStatus: "Complete", + }, + Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} diff --git a/server/aws/redshift/sdk_roundtrip_test.go b/server/aws/redshift/sdk_roundtrip_test.go index 2ff35813..911c5889 100644 --- a/server/aws/redshift/sdk_roundtrip_test.go +++ b/server/aws/redshift/sdk_roundtrip_test.go @@ -86,6 +86,41 @@ func TestSDKRedshiftCreateDescribeCluster(t *testing.T) { } } +// TestSDKRedshiftParameterAndSubnetGroups is a regression guard for issue +// #319: CreateClusterParameterGroup / CreateClusterSubnetGroup returned +// InvalidAction, blocking IaC that provisions a warehouse with a custom group. +func TestSDKRedshiftParameterAndSubnetGroups(t *testing.T) { + client := newSDKClient(t) + ctx := context.Background() + + pg, err := client.CreateClusterParameterGroup(ctx, &awsredshift.CreateClusterParameterGroupInput{ + ParameterGroupName: aws.String("pg1"), + ParameterGroupFamily: aws.String("redshift-1.0"), + Description: aws.String("my pg"), + }) + if err != nil { + t.Fatalf("CreateClusterParameterGroup: %v", err) + } + + if aws.ToString(pg.ClusterParameterGroup.ParameterGroupName) != "pg1" || + aws.ToString(pg.ClusterParameterGroup.ParameterGroupFamily) != "redshift-1.0" { + t.Fatalf("parameter group = %+v", pg.ClusterParameterGroup) + } + + sg, err := client.CreateClusterSubnetGroup(ctx, &awsredshift.CreateClusterSubnetGroupInput{ + ClusterSubnetGroupName: aws.String("sg1"), + Description: aws.String("my sg"), + SubnetIds: []string{"subnet-1", "subnet-2"}, + }) + if err != nil { + t.Fatalf("CreateClusterSubnetGroup: %v", err) + } + + if aws.ToString(sg.ClusterSubnetGroup.ClusterSubnetGroupName) != "sg1" { + t.Fatalf("subnet group = %+v", sg.ClusterSubnetGroup) + } +} + func TestSDKRedshiftClusterLifecycle(t *testing.T) { client := newSDKClient(t) ctx := context.Background() diff --git a/server/aws/redshift/tags.go b/server/aws/redshift/tags.go new file mode 100644 index 00000000..89429f57 --- /dev/null +++ b/server/aws/redshift/tags.go @@ -0,0 +1,106 @@ +package redshift + +import ( + "encoding/xml" + "net/http" + "sort" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" +) + +type taggedResourceXML struct { + ResourceName string `xml:"ResourceName"` + Key string `xml:"Tag>Key"` + Value string `xml:"Tag>Value"` +} + +type createTagsResponse struct { + XMLName xml.Name `xml:"CreateTagsResponse"` + Xmlns string `xml:"xmlns,attr"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type deleteTagsResponse struct { + XMLName xml.Name `xml:"DeleteTagsResponse"` + Xmlns string `xml:"xmlns,attr"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type describeTagsResponse struct { + XMLName xml.Name `xml:"DescribeTagsResponse"` + Xmlns string `xml:"xmlns,attr"` + Resources []taggedResourceXML `xml:"DescribeTagsResult>TaggedResources>TaggedResource"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +func (h *Handler) resourceTagger() (resourceTagger, bool) { + t, ok := h.db.(resourceTagger) + + return t, ok +} + +func (h *Handler) createTags(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.resourceTagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + if err := tagger.CreateTags(r.Context(), r.Form.Get("ResourceName"), awsquery.FlatTags(r.Form, "Tags.Tag")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, createTagsResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) deleteTags(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.resourceTagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + if err := tagger.DeleteTags(r.Context(), r.Form.Get("ResourceName"), awsquery.ListStrings(r.Form, "TagKeys.TagKey")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, deleteTagsResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) describeTags(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.resourceTagger() + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + resourceName := r.Form.Get("ResourceName") + + tags, err := tagger.DescribeTags(r.Context(), resourceName) + if err != nil { + writeErr(w, err) + return + } + + keys := make([]string, 0, len(tags)) + for k := range tags { + keys = append(keys, k) + } + sort.Strings(keys) + + out := make([]taggedResourceXML, 0, len(keys)) + for _, k := range keys { + out = append(out, taggedResourceXML{ResourceName: resourceName, Key: k, Value: tags[k]}) + } + + awsquery.WriteXMLResponse(w, describeTagsResponse{ + Xmlns: Namespace, Resources: out, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} diff --git a/server/aws/resourceexplorer2/handler.go b/server/aws/resourceexplorer2/handler.go index 71610243..ca0e8a11 100644 --- a/server/aws/resourceexplorer2/handler.go +++ b/server/aws/resourceexplorer2/handler.go @@ -55,10 +55,11 @@ type Handler struct { accountID string region string - mu sync.RWMutex - views map[string]*view // keyed by ViewArn - viewsByName map[string]string // ViewName → ViewArn, for collision detection - indexes map[string]*index // keyed by region + mu sync.RWMutex + views map[string]*view // keyed by ViewArn + viewsByName map[string]string // ViewName → ViewArn, for collision detection + indexes map[string]*index // keyed by region + defaultViewARN string // account default view (first created), for GetDefaultView } type view struct { @@ -103,14 +104,16 @@ func New(engine *resourcediscovery.Engine, accountID, region string) *Handler { // //nolint:gochecknoglobals // immutable lookup table. var knownPaths = map[string]struct{}{ - "/CreateView": {}, - "/DeleteView": {}, - "/ListViews": {}, - "/GetView": {}, - "/Search": {}, - "/ListResources": {}, - "/ListIndexes": {}, - "/GetIndex": {}, + "/CreateView": {}, + "/DeleteView": {}, + "/ListViews": {}, + "/GetView": {}, + "/Search": {}, + "/ListResources": {}, + "/ListIndexes": {}, + "/GetIndex": {}, + "/CreateIndex": {}, + "/GetDefaultView": {}, } // Matches returns true for POST requests whose path is one of the known @@ -143,6 +146,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.listIndexes(w, r) case "/GetIndex": h.getIndex(w, r) + case "/CreateIndex": + h.createIndex(w, r) + case "/GetDefaultView": + h.getDefaultView(w, r) default: wire.WriteJSONError(w, http.StatusNotFound, "ResourceNotFoundException", "unknown path: "+r.URL.Path) } @@ -189,6 +196,11 @@ func (h *Handler) createView(w http.ResponseWriter, r *http.Request) { h.views[arn] = v h.viewsByName[req.ViewName] = arn + // AWS auto-associates the first view in an account as its default view. + if h.defaultViewARN == "" { + h.defaultViewARN = arn + } + wire.WriteJSON(w, map[string]any{ "View": viewToWire(v, h.accountID), }) @@ -380,6 +392,36 @@ func (h *Handler) getIndex(w http.ResponseWriter, _ *http.Request) { }) } +// createIndex creates the LOCAL index for the calling region. Real Resource +// Explorer requires this before Search; the emulator bootstraps one at New(), +// so this is idempotent — it returns the existing index rather than erroring. +func (h *Handler) createIndex(w http.ResponseWriter, _ *http.Request) { + h.mu.Lock() + defer h.mu.Unlock() + + idx, ok := h.indexes[h.region] + if !ok { + idx = &index{ARN: h.indexARN(h.region), Region: h.region, Type: "LOCAL", CreatedAt: time.Now().UTC()} + h.indexes[h.region] = idx + } + + wire.WriteJSON(w, map[string]any{ + "Arn": idx.ARN, + "State": "CREATING", + "CreatedAt": idx.CreatedAt.Format(time.RFC3339), + }) +} + +// getDefaultView returns the account's default view. The first view created +// becomes the default (mirroring AWS auto-associating it); with no views the +// ViewArn is empty, which real Resource Explorer also returns. +func (h *Handler) getDefaultView(w http.ResponseWriter, _ *http.Request) { + h.mu.RLock() + defer h.mu.RUnlock() + + wire.WriteJSON(w, map[string]any{"ViewArn": h.defaultViewARN}) +} + func (h *Handler) viewARN(name string) string { return idgen.AWSARN("resource-explorer-2", h.region, h.accountID, "view/"+name+"/"+idgen.GenerateID("")) } diff --git a/server/aws/resourceexplorer2/sdk_test.go b/server/aws/resourceexplorer2/sdk_test.go index 84a6b92e..8cb423c9 100644 --- a/server/aws/resourceexplorer2/sdk_test.go +++ b/server/aws/resourceexplorer2/sdk_test.go @@ -176,6 +176,21 @@ func TestSDKResourceExplorer2_BugFixes(t *testing.T) { client := newREXClient(t, ts.URL) + t.Run("CreateIndex is idempotent and returns the local index", func(t *testing.T) { + out, err := client.CreateIndex(ctx, &rex.CreateIndexInput{}) + require.NoError(t, err, "CreateIndex must not return a 405/HTML deserialize error (#319 theme D)") + assert.NotEmpty(t, aws.ToString(out.Arn)) + }) + + t.Run("GetDefaultView returns the first created view", func(t *testing.T) { + created, err := client.CreateView(ctx, &rex.CreateViewInput{ViewName: aws.String("default-probe")}) + require.NoError(t, err) + + got, err := client.GetDefaultView(ctx, &rex.GetDefaultViewInput{}) + require.NoError(t, err, "GetDefaultView must not return a 405/HTML deserialize error (#319 theme D)") + assert.Equal(t, aws.ToString(created.View.ViewArn), aws.ToString(got.ViewArn)) + }) + t.Run("service:ec2 matches networking (not s3)", func(t *testing.T) { out, err := client.Search(ctx, &rex.SearchInput{ QueryString: aws.String("service:ec2"), diff --git a/server/aws/route53/handler.go b/server/aws/route53/handler.go index 80285851..4d51877a 100644 --- a/server/aws/route53/handler.go +++ b/server/aws/route53/handler.go @@ -28,6 +28,9 @@ import ( // pathPrefix roots every Route 53 REST URL. The version segment is fixed. const pathPrefix = "/2013-04-01/hostedzone" +// tagsPrefix roots the Route 53 tagging API: /2013-04-01/tags/{type}/{id}. +const tagsPrefix = "/2013-04-01/tags/" + const rrsetSeg = "rrset" // Handler serves Route 53 REST requests against a dns driver. @@ -44,11 +47,18 @@ func New(d dnsdriver.DNS) *Handler { // path space, disjoint from every other AWS handler. Registered before the S3 // REST fallback so those paths aren't swallowed by the catch-all. func (*Handler) Matches(r *http.Request) bool { - return r.URL.Path == pathPrefix || strings.HasPrefix(r.URL.Path, pathPrefix+"/") + return r.URL.Path == pathPrefix || + strings.HasPrefix(r.URL.Path, pathPrefix+"/") || + strings.HasPrefix(r.URL.Path, tagsPrefix) } // ServeHTTP routes on the path tail and method. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, tagsPrefix) { + h.serveTags(w, r, strings.TrimPrefix(r.URL.Path, tagsPrefix)) + return + } + tail := strings.Trim(strings.TrimPrefix(r.URL.Path, pathPrefix), "/") if tail == "" { h.serveZoneCollection(w, r) diff --git a/server/aws/route53/tags.go b/server/aws/route53/tags.go new file mode 100644 index 00000000..5ad0865a --- /dev/null +++ b/server/aws/route53/tags.go @@ -0,0 +1,94 @@ +package route53 + +import ( + "context" + "encoding/xml" + "net/http" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// resourceTagger is the AWS-specific Route 53 tagging surface, asserted against +// the provider (not part of the portable DNS driver). +type resourceTagger interface { + ChangeResourceTags(ctx context.Context, resourceID string, add map[string]string, remove []string) error + ListResourceTags(ctx context.Context, resourceID string) (map[string]string, error) +} + +type r53Tag struct { + Key string `xml:"Key"` + Value string `xml:"Value"` +} + +type changeTagsRequest struct { + XMLName xml.Name `xml:"ChangeTagsForResourceRequest"` + AddTags []r53Tag `xml:"AddTags>Tag"` + RemoveTagKeys []string `xml:"RemoveTagKeys>Key"` +} + +type resourceTagSetXML struct { + ResourceType string `xml:"ResourceType"` + ResourceID string `xml:"ResourceId"` + Tags []r53Tag `xml:"Tags>Tag"` +} + +type listTagsForResourceResponse struct { + XMLName xml.Name `xml:"ListTagsForResourceResponse"` + ResourceTagSet resourceTagSetXML `xml:"ResourceTagSet"` +} + +type changeTagsForResourceResponse struct { + XMLName xml.Name `xml:"ChangeTagsForResourceResponse"` +} + +// serveTags handles /2013-04-01/tags/{ResourceType}/{ResourceId}: +// POST=ChangeTagsForResource, GET=ListTagsForResource. +func (h *Handler) serveTags(w http.ResponseWriter, r *http.Request, tail string) { + tagger, ok := h.dns.(resourceTagger) + if !ok { + writeError(w, http.StatusNotImplemented, "InvalidInput", "tagging not supported") + return + } + + resourceType, resourceID, _ := strings.Cut(tail, "/") + if resourceID == "" { + writeError(w, http.StatusBadRequest, "InvalidInput", "resource id is required") + return + } + + switch r.Method { + case http.MethodPost: + var req changeTagsRequest + if !decodeXML(w, r, &req) { + return + } + + add := make(map[string]string, len(req.AddTags)) + for _, t := range req.AddTags { + add[t.Key] = t.Value + } + + if err := tagger.ChangeResourceTags(r.Context(), resourceID, add, req.RemoveTagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteXML(w, http.StatusOK, changeTagsForResourceResponse{}) + case http.MethodGet: + tags, err := tagger.ListResourceTags(r.Context(), resourceID) + if err != nil { + writeErr(w, err) + return + } + + set := resourceTagSetXML{ResourceType: resourceType, ResourceID: resourceID} + for k, v := range tags { + set.Tags = append(set.Tags, r53Tag{Key: k, Value: v}) + } + + wire.WriteXML(w, http.StatusOK, listTagsForResourceResponse{ResourceTagSet: set}) + default: + writeMethodNotAllowed(w) + } +} diff --git a/server/aws/s3/handler.go b/server/aws/s3/handler.go index 24a28974..9ca07a34 100644 --- a/server/aws/s3/handler.go +++ b/server/aws/s3/handler.go @@ -126,6 +126,12 @@ func (h *Handler) bucketOp(w http.ResponseWriter, r *http.Request, bucket string q := r.URL.Query() switch { + case q.Has("tagging"): + h.bucketTaggingOp(w, r, bucket) + return + case q.Has("notification"): + h.bucketNotificationOp(w, r, bucket) + return case q.Has("versioning"): h.bucketVersioningOp(w, r, bucket) return @@ -156,6 +162,83 @@ func (h *Handler) bucketOp(w http.ResponseWriter, r *http.Request, bucket string h.deleteBucket(w, r, bucket) case http.MethodGet: h.listObjects(w, r, bucket) + case http.MethodHead: + h.headBucket(w, r, bucket) + default: + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") + } +} + +// headBucket answers HEAD /{bucket}: 200 if the bucket exists, 404 otherwise. +// It backs the SDK's HeadBucket / bucket-exists waiters. +func (h *Handler) headBucket(w http.ResponseWriter, r *http.Request, bucket string) { + buckets, err := h.bucket.ListBuckets(r.Context()) + if err != nil { + writeErr(w, err) + return + } + + for _, b := range buckets { + if b.Name == bucket { + w.WriteHeader(http.StatusOK) + return + } + } + + // HEAD carries no body, so the SDK infers NoSuchBucket from the 404 status. + w.WriteHeader(http.StatusNotFound) +} + +// bucketTaggingOp dispatches PUT/GET/DELETE for the bucket ?tagging +// sub-resource. Without this, a PUT ?tagging fell through to CreateBucket and +// failed with BucketAlreadyOwnedByYou. +func (h *Handler) bucketTaggingOp(w http.ResponseWriter, r *http.Request, bucket string) { + switch r.Method { + case http.MethodPut: + var body tagging + if err := xml.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "MalformedXML", "could not parse request body") + return + } + + tags := make(map[string]string, len(body.TagSet)) + for _, t := range body.TagSet { + tags[t.Key] = t.Value + } + + if err := h.bucket.PutBucketTagging(r.Context(), bucket, tags); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + case http.MethodGet: + tags, err := h.bucket.GetBucketTagging(r.Context(), bucket) + if err != nil { + writeErr(w, err) + return + } + + resp := tagging{Xmlns: xmlns} + + keys := make([]string, 0, len(tags)) + for k := range tags { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + resp.TagSet = append(resp.TagSet, tagXML{Key: k, Value: tags[k]}) + } + + wire.WriteXML(w, http.StatusOK, resp) + case http.MethodDelete: + if err := h.bucket.DeleteBucketTagging(r.Context(), bucket); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) default: writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") } @@ -181,10 +264,29 @@ func (h *Handler) deleteBucket(w http.ResponseWriter, r *http.Request, bucket st } func (h *Handler) listObjects(w http.ResponseWriter, r *http.Request, bucket string) { + q := r.URL.Query() + + // A client-supplied continuation-token (ListObjectsV2) or marker + // (ListObjects v1) both resume paging; accept either. + pageToken := q.Get("continuation-token") + if pageToken == "" { + pageToken = q.Get("marker") + } + opts := driver.ListOptions{ - Prefix: r.URL.Query().Get("prefix"), - Delimiter: r.URL.Query().Get("delimiter"), - PageToken: r.URL.Query().Get("continuation-token"), + Prefix: q.Get("prefix"), + Delimiter: q.Get("delimiter"), + PageToken: pageToken, + } + + // max-keys caps the page; an absent or unparseable value leaves the driver + // default in place. Previously ignored, so large buckets never truncated. + maxKeys := defaultMaxKeys + if v := q.Get("max-keys"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + opts.MaxKeys = n + maxKeys = n + } } result, err := h.bucket.ListObjects(r.Context(), bucket, opts) @@ -198,7 +300,7 @@ func (h *Handler) listObjects(w http.ResponseWriter, r *http.Request, bucket str Name: bucket, Prefix: opts.Prefix, Delimiter: opts.Delimiter, - MaxKeys: defaultMaxKeys, + MaxKeys: maxKeys, IsTruncated: result.IsTruncated, KeyCount: len(result.Objects), } diff --git a/server/aws/s3/notification.go b/server/aws/s3/notification.go new file mode 100644 index 00000000..0c4125b7 --- /dev/null +++ b/server/aws/s3/notification.go @@ -0,0 +1,81 @@ +package s3 + +import ( + "context" + "encoding/xml" + "net/http" + + s3provider "github.com/stackshy/cloudemu/v2/providers/aws/s3" + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// bucketNotifier is the AWS-specific bucket-notification surface. It's not part +// of the portable Bucket driver (Azure Blob / GCS notify differently), so the +// handler type-asserts for it. +type bucketNotifier interface { + PutBucketNotification(ctx context.Context, bucket string, configs []s3provider.QueueNotification) error + GetBucketNotification(ctx context.Context, bucket string) ([]s3provider.QueueNotification, error) +} + +type queueConfigurationXML struct { + ID string `xml:"Id,omitempty"` + Queue string `xml:"Queue"` + Events []string `xml:"Event"` +} + +type notificationConfigurationXML struct { + XMLName xml.Name `xml:"NotificationConfiguration"` + Xmlns string `xml:"xmlns,attr,omitempty"` + QueueConfigurations []queueConfigurationXML `xml:"QueueConfiguration"` +} + +// bucketNotificationOp dispatches PUT/GET for the bucket ?notification +// sub-resource. Without this a PUT ?notification fell through to CreateBucket +// (BucketAlreadyOwnedByYou), and S3 -> SQS event pipelines could not be wired. +func (h *Handler) bucketNotificationOp(w http.ResponseWriter, r *http.Request, bucket string) { + notifier, ok := h.bucket.(bucketNotifier) + if !ok { + writeError(w, http.StatusNotImplemented, "NotImplemented", "notifications not supported") + return + } + + switch r.Method { + case http.MethodPut: + var body notificationConfigurationXML + if err := xml.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "MalformedXML", "could not parse request body") + return + } + + configs := make([]s3provider.QueueNotification, 0, len(body.QueueConfigurations)) + for _, qc := range body.QueueConfigurations { + configs = append(configs, s3provider.QueueNotification{ + ID: qc.ID, QueueARN: qc.Queue, Events: qc.Events, + }) + } + + if err := notifier.PutBucketNotification(r.Context(), bucket, configs); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) + case http.MethodGet: + configs, err := notifier.GetBucketNotification(r.Context(), bucket) + if err != nil { + writeErr(w, err) + return + } + + resp := notificationConfigurationXML{Xmlns: xmlns} + for _, c := range configs { + resp.QueueConfigurations = append(resp.QueueConfigurations, queueConfigurationXML{ + ID: c.ID, Queue: c.QueueARN, Events: c.Events, + }) + } + + wire.WriteXML(w, http.StatusOK, resp) + default: + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") + } +} diff --git a/server/aws/s3/sdk_roundtrip_test.go b/server/aws/s3/sdk_roundtrip_test.go index 0cf71db5..81156d7e 100644 --- a/server/aws/s3/sdk_roundtrip_test.go +++ b/server/aws/s3/sdk_roundtrip_test.go @@ -264,6 +264,99 @@ func TestSDKObjectTagging(t *testing.T) { } } +// TestSDKHeadBucketAndTagging is a regression guard for issue #319: HeadBucket +// (HEAD /{bucket}) returned 405, and PutBucketTagging (PUT /{bucket}?tagging) +// mis-routed to CreateBucket and failed with BucketAlreadyOwnedByYou. +func TestSDKHeadBucketAndTagging(t *testing.T) { + client := newSDKClient(t) + ctx := context.Background() + + const bucket = "hb-bucket" + + mustCreateBucket(t, client, bucket) + + // HeadBucket on an existing bucket succeeds. + if _, err := client.HeadBucket(ctx, &awss3.HeadBucketInput{Bucket: aws.String(bucket)}); err != nil { + t.Fatalf("HeadBucket(existing): %v", err) + } + + // HeadBucket on a missing bucket is an error (404). + if _, err := client.HeadBucket(ctx, &awss3.HeadBucketInput{Bucket: aws.String("ghost")}); err == nil { + t.Fatal("HeadBucket(missing): expected error, got nil") + } + + // PutBucketTagging round-trips through the ?tagging sub-resource. + if _, err := client.PutBucketTagging(ctx, &awss3.PutBucketTaggingInput{ + Bucket: aws.String(bucket), + Tagging: &types.Tagging{TagSet: []types.Tag{ + {Key: aws.String("env"), Value: aws.String("prod")}, + }}, + }); err != nil { + t.Fatalf("PutBucketTagging: %v", err) + } + + got, err := client.GetBucketTagging(ctx, &awss3.GetBucketTaggingInput{Bucket: aws.String(bucket)}) + if err != nil { + t.Fatalf("GetBucketTagging: %v", err) + } + + if len(got.TagSet) != 1 || aws.ToString(got.TagSet[0].Key) != "env" { + t.Fatalf("GetBucketTagging = %+v", got.TagSet) + } + + if _, err := client.DeleteBucketTagging(ctx, &awss3.DeleteBucketTaggingInput{Bucket: aws.String(bucket)}); err != nil { + t.Fatalf("DeleteBucketTagging: %v", err) + } +} + +// TestSDKListObjectsV2MaxKeys is a regression guard for issue #319: +// ListObjectsV2 ignored MaxKeys, returned every key with IsTruncated=false and +// no continuation token, breaking any client that pages large buckets. +func TestSDKListObjectsV2MaxKeys(t *testing.T) { + client := newSDKClient(t) + ctx := context.Background() + + const bucket = "paged-bucket" + + mustCreateBucket(t, client, bucket) + + for _, k := range []string{"k1", "k2", "k3", "k4", "k5"} { + if _, err := client.PutObject(ctx, &awss3.PutObjectInput{ + Bucket: aws.String(bucket), Key: aws.String(k), Body: bytes.NewReader([]byte("x")), + }); err != nil { + t.Fatalf("PutObject %s: %v", k, err) + } + } + + first, err := client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{ + Bucket: aws.String(bucket), MaxKeys: aws.Int32(2), + }) + if err != nil { + t.Fatalf("ListObjectsV2 page 1: %v", err) + } + + if len(first.Contents) != 2 || !aws.ToBool(first.IsTruncated) || aws.ToString(first.NextContinuationToken) == "" { + t.Fatalf("page 1: got %d keys, truncated=%v, token=%q", + len(first.Contents), aws.ToBool(first.IsTruncated), aws.ToString(first.NextContinuationToken)) + } + + second, err := client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{ + Bucket: aws.String(bucket), MaxKeys: aws.Int32(2), + ContinuationToken: first.NextContinuationToken, + }) + if err != nil { + t.Fatalf("ListObjectsV2 page 2: %v", err) + } + + if len(second.Contents) != 2 { + t.Fatalf("page 2: got %d keys, want 2", len(second.Contents)) + } + + if aws.ToString(first.Contents[0].Key) == aws.ToString(second.Contents[0].Key) { + t.Fatal("page 2 returned the same first key as page 1 — pagination not advancing") + } +} + // TestSDKBucketVersioning verifies PutBucketVersioning(Enabled) -> // GetBucketVersioning returns Enabled. func TestSDKBucketVersioning(t *testing.T) { diff --git a/server/aws/secretsmanager/handler.go b/server/aws/secretsmanager/handler.go index 4573ff30..ac508b05 100644 --- a/server/aws/secretsmanager/handler.go +++ b/server/aws/secretsmanager/handler.go @@ -8,6 +8,7 @@ package secretsmanager import ( + "context" "net/http" "strings" @@ -18,6 +19,16 @@ import ( const targetPrefix = "secretsmanager." +// secretMutator is the AWS-specific UpdateSecret + tagging surface. These are +// not part of the portable Secrets driver (Azure Key Vault and GCP Secret +// Manager also implement it), so the handler type-asserts for them rather than +// widening the shared interface. +type secretMutator interface { + UpdateSecret(ctx context.Context, name, description string, value []byte) (*secretsdriver.SecretInfo, error) + TagSecret(ctx context.Context, name string, tags map[string]string) error + UntagSecret(ctx context.Context, name string, keys []string) error +} + // Handler serves Secrets Manager JSON-RPC requests against a Secrets driver. type Handler struct { secrets secretsdriver.Secrets @@ -51,6 +62,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.putSecretValue(w, r) case "ListSecretVersionIds": h.listSecretVersionIDs(w, r) + case "UpdateSecret": + h.updateSecret(w, r) + case "TagResource": + h.tagResource(w, r) + case "UntagResource": + h.untagResource(w, r) default: op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) wire.WriteJSONError(w, http.StatusBadRequest, @@ -58,6 +75,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +// errNotSupported is returned when the backing driver doesn't implement the +// AWS-specific secretMutator surface. Real deployments always do. +var errNotSupported = cerrors.New(cerrors.Unimplemented, "operation not supported by this backend") + // writeErr maps canonical cloudemu errors to Secrets Manager JSON error // responses. Secrets Manager returns errors as HTTP 400 with a "__type" body // the SDK maps to a typed exception. diff --git a/server/aws/secretsmanager/operations.go b/server/aws/secretsmanager/operations.go index 2e95b3a1..1fc977ab 100644 --- a/server/aws/secretsmanager/operations.go +++ b/server/aws/secretsmanager/operations.go @@ -176,3 +176,65 @@ func (h *Handler) listSecretVersionIDs(w http.ResponseWriter, r *http.Request) { wire.WriteJSON(w, listSecretVersionIDsResponse{ARN: info.ResourceID, Name: info.Name, Versions: out}) } + +func (h *Handler) updateSecret(w http.ResponseWriter, r *http.Request) { + mut, ok := h.secrets.(secretMutator) + if !ok { + writeErr(w, errNotSupported) + return + } + + var req updateSecretRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + info, err := mut.UpdateSecret(r.Context(), resolveSecretID(req.SecretID), + req.Description, secretValue(req.SecretString, req.SecretBinary)) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, updateSecretResponse{ARN: info.ResourceID, Name: info.Name}) +} + +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request) { + mut, ok := h.secrets.(secretMutator) + if !ok { + writeErr(w, errNotSupported) + return + } + + var req tagResourceRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := mut.TagSecret(r.Context(), resolveSecretID(req.SecretID), tagsToMap(req.Tags)); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { + mut, ok := h.secrets.(secretMutator) + if !ok { + writeErr(w, errNotSupported) + return + } + + var req untagResourceRequest + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := mut.UntagSecret(r.Context(), resolveSecretID(req.SecretID), req.TagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} diff --git a/server/aws/secretsmanager/sdk_roundtrip_test.go b/server/aws/secretsmanager/sdk_roundtrip_test.go index d0659e78..79da767d 100644 --- a/server/aws/secretsmanager/sdk_roundtrip_test.go +++ b/server/aws/secretsmanager/sdk_roundtrip_test.go @@ -96,6 +96,66 @@ func TestSDKSecretLifecycle(t *testing.T) { } } +// TestSDKUpdateSecretAndTagging is a regression guard for issue #319: +// UpdateSecret, TagResource, and UntagResource were unimplemented. +func TestSDKUpdateSecretAndTagging(t *testing.T) { + client := newSecretsClient(t) + ctx := context.Background() + + if _, err := client.CreateSecret(ctx, &awssm.CreateSecretInput{ + Name: aws.String("s"), Description: aws.String("d1"), SecretString: aws.String("v1"), + }); err != nil { + t.Fatalf("CreateSecret: %v", err) + } + + // UpdateSecret changes description and value. + if _, err := client.UpdateSecret(ctx, &awssm.UpdateSecretInput{ + SecretId: aws.String("s"), Description: aws.String("d2"), SecretString: aws.String("v2"), + }); err != nil { + t.Fatalf("UpdateSecret: %v", err) + } + + val, err := client.GetSecretValue(ctx, &awssm.GetSecretValueInput{SecretId: aws.String("s")}) + if err != nil { + t.Fatalf("GetSecretValue: %v", err) + } + + if aws.ToString(val.SecretString) != "v2" { + t.Fatalf("value = %q, want v2", aws.ToString(val.SecretString)) + } + + // TagResource then UntagResource. + if _, err := client.TagResource(ctx, &awssm.TagResourceInput{ + SecretId: aws.String("s"), Tags: []smtypes.Tag{{Key: aws.String("env"), Value: aws.String("prod")}}, + }); err != nil { + t.Fatalf("TagResource: %v", err) + } + + desc, err := client.DescribeSecret(ctx, &awssm.DescribeSecretInput{SecretId: aws.String("s")}) + if err != nil { + t.Fatalf("DescribeSecret: %v", err) + } + + if aws.ToString(desc.Description) != "d2" || len(desc.Tags) != 1 { + t.Fatalf("after update+tag: description=%q tags=%+v", aws.ToString(desc.Description), desc.Tags) + } + + if _, err := client.UntagResource(ctx, &awssm.UntagResourceInput{ + SecretId: aws.String("s"), TagKeys: []string{"env"}, + }); err != nil { + t.Fatalf("UntagResource: %v", err) + } + + desc, err = client.DescribeSecret(ctx, &awssm.DescribeSecretInput{SecretId: aws.String("s")}) + if err != nil { + t.Fatalf("DescribeSecret after untag: %v", err) + } + + if len(desc.Tags) != 0 { + t.Fatalf("tags after untag = %+v, want none", desc.Tags) + } +} + func TestSDKSecretValueVersioning(t *testing.T) { client := newSecretsClient(t) ctx := context.Background() diff --git a/server/aws/secretsmanager/types.go b/server/aws/secretsmanager/types.go index fc94bc81..ffa522f0 100644 --- a/server/aws/secretsmanager/types.go +++ b/server/aws/secretsmanager/types.go @@ -59,6 +59,28 @@ type putSecretValueRequest struct { SecretBinary []byte `json:"SecretBinary"` } +type updateSecretRequest struct { + SecretID string `json:"SecretId"` + Description string `json:"Description"` + SecretString string `json:"SecretString"` + SecretBinary []byte `json:"SecretBinary"` +} + +type tagResourceRequest struct { + SecretID string `json:"SecretId"` + Tags []tagJSON `json:"Tags"` +} + +type untagResourceRequest struct { + SecretID string `json:"SecretId"` + TagKeys []string `json:"TagKeys"` +} + +type updateSecretResponse struct { + ARN string `json:"ARN"` + Name string `json:"Name"` +} + // --- response envelopes --- type createSecretResponse struct { diff --git a/server/aws/sns/handler.go b/server/aws/sns/handler.go index 8012def3..86e62c58 100644 --- a/server/aws/sns/handler.go +++ b/server/aws/sns/handler.go @@ -24,6 +24,7 @@ package sns import ( + "context" "net/http" "strings" @@ -47,12 +48,28 @@ var snsActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup "CreateTopic": {}, "DeleteTopic": {}, "GetTopicAttributes": {}, + "SetTopicAttributes": {}, "ListTopics": {}, "Subscribe": {}, "Unsubscribe": {}, "ListSubscriptions": {}, "ListSubscriptionsByTopic": {}, "Publish": {}, + "TagResource": {}, + "UntagResource": {}, +} + +// topicTagger is the AWS-specific topic-tagging surface. It's not part of the +// portable Notification driver (Azure Notification Hubs and GCP FCM also +// implement it), so the handler type-asserts for it. +// +// ListTagsForResource is intentionally omitted: that action name collides with +// RDS in the shared query protocol (RDS registers first and claims it), and +// disambiguating would require SigV4 credential-scope routing. SNS tag writes +// (the flagged gap) work; tag read-back is a follow-up. +type topicTagger interface { + TagTopic(ctx context.Context, topicName string, tags map[string]string) error + UntagTopic(ctx context.Context, topicName string, keys []string) error } // Handler serves SNS query-protocol requests against a notification driver. @@ -103,6 +120,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.deleteTopic(w, r) case "GetTopicAttributes": h.getTopicAttributes(w, r) + case "SetTopicAttributes": + h.setTopicAttributes(w, r) case "ListTopics": h.listTopics(w, r) case "Subscribe": @@ -115,6 +134,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.listSubscriptionsByTopic(w, r) case "Publish": h.publish(w, r) + case "TagResource": + h.tagResource(w, r) + case "UntagResource": + h.untagResource(w, r) default: awsquery.WriteXMLError(w, http.StatusBadRequest, "InvalidAction", "unknown SNS action: "+action) diff --git a/server/aws/sns/operations.go b/server/aws/sns/operations.go index 2f46bde5..fb4e9012 100644 --- a/server/aws/sns/operations.go +++ b/server/aws/sns/operations.go @@ -11,6 +11,69 @@ import ( "github.com/stackshy/cloudemu/v2/services/scope" ) +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.notif.(topicTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + name := topicNameFromARN(r.Form.Get("ResourceArn")) + + if err := tagger.TagTopic(r.Context(), name, awsquery.FlatTags(r.Form, "Tags.member")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, tagResourceResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.notif.(topicTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + name := topicNameFromARN(r.Form.Get("ResourceArn")) + + if err := tagger.UntagTopic(r.Context(), name, awsquery.ListStrings(r.Form, "TagKeys.member")); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, untagResourceResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + +// parseMessageAttributes reads SNS Publish MessageAttributes.entry.N.Name / +// .Value.StringValue form parameters into a flat name->value map. Only string +// values are modeled (the common case); binary values are ignored. +func parseMessageAttributes(form url.Values) map[string]string { + idx := awsquery.CollectIndices(form, "MessageAttributes.entry") + if len(idx) == 0 { + return nil + } + + out := make(map[string]string, len(idx)) + + for _, i := range idx { + base := "MessageAttributes.entry." + strconv.Itoa(i) + + name := form.Get(base + ".Name") + if name == "" { + continue + } + + out[name] = form.Get(base + ".Value.StringValue") + } + + return out +} + // createTopic maps CreateTopic to Notification.CreateTopic. SNS CreateTopic is // idempotent: creating a topic that already exists returns the existing ARN // rather than an error, so we translate the driver's AlreadyExists into a @@ -63,6 +126,27 @@ func (h *Handler) deleteTopic(w http.ResponseWriter, r *http.Request) { // getTopicAttributes maps GetTopicAttributes to Notification.GetTopic and // exposes the topic's ARN, display name, and subscription count as the standard // SNS attribute map. +// setTopicAttributes maps SetTopicAttributes to Notification.UpdateTopic for +// the DisplayName attribute. Other attribute names (Policy, DeliveryPolicy) are +// accepted but not modeled — the emulator doesn't evaluate topic policies, so +// storing them would have no observable effect. +func (h *Handler) setTopicAttributes(w http.ResponseWriter, r *http.Request) { + name := topicNameFromARN(r.Form.Get("TopicArn")) + + if r.Form.Get("AttributeName") == "DisplayName" { + if _, err := h.notif.UpdateTopic(r.Context(), notifdriver.TopicConfig{ + Name: name, DisplayName: r.Form.Get("AttributeValue"), + }); err != nil { + writeErr(w, err) + return + } + } + + awsquery.WriteXMLResponse(w, setTopicAttributesResponse{ + Xmlns: Namespace, Metadata: responseMetadata{RequestID: awsquery.RequestID}, + }) +} + func (h *Handler) getTopicAttributes(w http.ResponseWriter, r *http.Request) { name := topicNameFromARN(r.Form.Get("TopicArn")) @@ -197,9 +281,10 @@ func (h *Handler) publish(w http.ResponseWriter, r *http.Request) { } out, err := h.notif.Publish(r.Context(), notifdriver.PublishInput{ - TopicID: topicNameFromARN(arn), - Subject: r.Form.Get("Subject"), - Message: r.Form.Get("Message"), + TopicID: topicNameFromARN(arn), + Subject: r.Form.Get("Subject"), + Message: r.Form.Get("Message"), + Attributes: parseMessageAttributes(r.Form), }) if err != nil { writeErr(w, err) diff --git a/server/aws/sns/types.go b/server/aws/sns/types.go index 242677fd..0b7be42e 100644 --- a/server/aws/sns/types.go +++ b/server/aws/sns/types.go @@ -38,6 +38,31 @@ type unsubscribeResponse struct { Metadata responseMetadata `xml:"ResponseMetadata"` } +type setTopicAttributesResponse struct { + XMLName xml.Name `xml:"SetTopicAttributesResponse"` + Xmlns string `xml:"xmlns,attr"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +// --- TagResource / UntagResource (empty results) --- +// +// The SDK's SNS unmarshaler expects the empty wrapper element, so +// it's included even though it carries no data. + +type tagResourceResponse struct { + XMLName xml.Name `xml:"TagResourceResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct{} `xml:"TagResourceResult"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + +type untagResourceResponse struct { + XMLName xml.Name `xml:"UntagResourceResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct{} `xml:"UntagResourceResult"` + Metadata responseMetadata `xml:"ResponseMetadata"` +} + // --- GetTopicAttributes --- type attributeEntry struct { diff --git a/server/aws/sqs/handler.go b/server/aws/sqs/handler.go index c22671d7..a9680b55 100644 --- a/server/aws/sqs/handler.go +++ b/server/aws/sqs/handler.go @@ -2,14 +2,17 @@ // Modern aws-sdk-go-v2 SQS uses AwsJson1_0 with X-Amz-Target headers (since // SQS migrated off the legacy Query protocol in 2023). // -// MVP coverage: queue lifecycle + the synchronous send/receive/delete loop -// every consumer needs. Batch ops, ChangeMessageVisibility, attributes, and -// PurgeQueue are deferred to a follow-up — the portable +// Coverage: queue lifecycle, the synchronous send/receive/delete loop, +// queue attributes (GetQueueAttributes exposes the QueueArn that event-source +// mappings, DLQ wiring, and S3->SQS notifications depend on), and PurgeQueue. +// Batch ops and ChangeMessageVisibility remain deferred — the portable // messagequeue.MessageQueue driver supports them. package sqs import ( + "context" "net/http" + "strconv" "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" @@ -56,6 +59,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.receiveMessage(w, r) case "DeleteMessage": h.deleteMessage(w, r) + case "GetQueueAttributes": + h.getQueueAttributes(w, r) + case "SetQueueAttributes": + h.setQueueAttributes(w, r) + case "PurgeQueue": + h.purgeQueue(w, r) + case "TagQueue": + h.tagQueue(w, r) + case "UntagQueue": + h.untagQueue(w, r) + case "ListQueueTags": + h.listQueueTags(w, r) default: wire.WriteJSONError(w, http.StatusBadRequest, "UnknownOperationException", "unknown operation: "+op) @@ -240,6 +255,200 @@ func (h *Handler) deleteMessage(w http.ResponseWriter, r *http.Request) { wire.WriteJSON(w, map[string]any{}) } +// queueTagger is the AWS-specific SQS tagging surface. It's not part of the +// portable MessageQueue driver, so the handler type-asserts for it. +type queueTagger interface { + TagQueue(ctx context.Context, queueURL string, tags map[string]string) error + UntagQueue(ctx context.Context, queueURL string, keys []string) error + ListQueueTags(ctx context.Context, queueURL string) (map[string]string, error) +} + +func (h *Handler) tagQueue(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.mq.(queueTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + QueueURL string `json:"QueueUrl"` + Tags map[string]string `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := tagger.TagQueue(r.Context(), req.QueueURL, req.Tags); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{}) +} + +func (h *Handler) untagQueue(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.mq.(queueTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + QueueURL string `json:"QueueUrl"` + TagKeys []string `json:"TagKeys"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := tagger.UntagQueue(r.Context(), req.QueueURL, req.TagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{}) +} + +func (h *Handler) listQueueTags(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.mq.(queueTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + QueueURL string `json:"QueueUrl"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags, err := tagger.ListQueueTags(r.Context(), req.QueueURL) + if err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{"Tags": tags}) +} + +// numericAttrKeys are the SetQueueAttributes attributes the provider applies. +var numericAttrKeys = []string{ + "DelaySeconds", "VisibilityTimeout", "MaximumMessageSize", + "MessageRetentionPeriod", "ReceiveMessageWaitTimeSeconds", +} + +func (h *Handler) getQueueAttributes(w http.ResponseWriter, r *http.Request) { + var req struct { + QueueURL string `json:"QueueUrl"` + AttributeNames []string `json:"AttributeNames"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + attrs, err := h.mq.GetQueueAttributes(r.Context(), req.QueueURL) + if err != nil { + writeErr(w, err) + return + } + + info, err := h.mq.GetQueueInfo(r.Context(), req.QueueURL) + if err != nil { + writeErr(w, err) + return + } + + all := map[string]string{ + "QueueArn": info.ARN, + "ApproximateNumberOfMessages": strconv.Itoa(attrs.ApproximateMessageCount), + "ApproximateNumberOfMessagesNotVisible": strconv.Itoa(attrs.ApproximateNotVisibleCount), + "VisibilityTimeout": strconv.Itoa(attrs.VisibilityTimeout), + "DelaySeconds": strconv.Itoa(attrs.DelaySeconds), + "MaximumMessageSize": strconv.Itoa(attrs.MaximumMessageSize), + "MessageRetentionPeriod": strconv.Itoa(attrs.MessageRetentionPeriod), + "CreatedTimestamp": strconv.FormatInt(attrs.CreatedAt.Unix(), 10), + "LastModifiedTimestamp": strconv.FormatInt(attrs.LastModifiedAt.Unix(), 10), + "FifoQueue": strconv.FormatBool(attrs.FifoQueue), + } + if attrs.RedrivePolicy != "" { + all["RedrivePolicy"] = attrs.RedrivePolicy + } + + wire.WriteJSON(w, map[string]any{"Attributes": selectAttributes(all, req.AttributeNames)}) +} + +// selectAttributes returns the requested subset, or all when the caller asks +// for "All" or names nothing (real SQS semantics). +func selectAttributes(all map[string]string, names []string) map[string]string { + if len(names) == 0 { + return all + } + + for _, n := range names { + if n == "All" { + return all + } + } + + out := make(map[string]string, len(names)) + for _, n := range names { + if v, ok := all[n]; ok { + out[n] = v + } + } + + return out +} + +func (h *Handler) setQueueAttributes(w http.ResponseWriter, r *http.Request) { + var req struct { + QueueURL string `json:"QueueUrl"` + Attributes map[string]string `json:"Attributes"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + attrs := make(map[string]int, len(numericAttrKeys)) + for _, k := range numericAttrKeys { + if v, ok := req.Attributes[k]; ok { + if n, err := strconv.Atoi(v); err == nil { + attrs[k] = n + } + } + } + + if err := h.mq.SetQueueAttributes(r.Context(), req.QueueURL, attrs); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{}) +} + +func (h *Handler) purgeQueue(w http.ResponseWriter, r *http.Request) { + var req struct { + QueueURL string `json:"QueueUrl"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := h.mq.PurgeQueue(r.Context(), req.QueueURL); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, map[string]any{}) +} + // writeErr maps CloudEmu canonical errors to SQS-shaped HTTP error responses. func writeErr(w http.ResponseWriter, err error) { switch { diff --git a/server/aws/sqs/sqs_test.go b/server/aws/sqs/sqs_test.go index 3e05641f..e12c947e 100644 --- a/server/aws/sqs/sqs_test.go +++ b/server/aws/sqs/sqs_test.go @@ -179,6 +179,78 @@ func TestUnknownOperation(t *testing.T) { // helpers -------------------------------------------------------------------- +// TestQueueAttributesAndPurge is a regression guard for issue #319: the SQS +// handler previously did not dispatch GetQueueAttributes/SetQueueAttributes/ +// PurgeQueue, so callers couldn't read a queue's ARN (needed for DLQ wiring, +// event-source mappings, and S3->SQS notifications) or resize/drain a queue. +func TestQueueAttributesAndPurge(t *testing.T) { + srv, _ := newServer(t) + + create := postJSON(t, srv, "AmazonSQS.CreateQueue", `{"QueueName":"attrq"}`) + qurl := extractQueueURL(t, create) + + // GetQueueAttributes(All) must surface QueueArn. + got := readBody(t, postJSON(t, srv, "AmazonSQS.GetQueueAttributes", + `{"QueueUrl":"`+qurl+`","AttributeNames":["All"]}`)) + if !strings.Contains(got, `"QueueArn":"arn:aws:sqs:`) { + t.Fatalf("GetQueueAttributes missing QueueArn: %s", got) + } + + // SetQueueAttributes persists a numeric attribute. + if resp := postJSON(t, srv, "AmazonSQS.SetQueueAttributes", + `{"QueueUrl":"`+qurl+`","Attributes":{"VisibilityTimeout":"45"}}`); resp.StatusCode != http.StatusOK { + t.Fatalf("SetQueueAttributes status = %d", resp.StatusCode) + } + + got = readBody(t, postJSON(t, srv, "AmazonSQS.GetQueueAttributes", + `{"QueueUrl":"`+qurl+`","AttributeNames":["VisibilityTimeout"]}`)) + if !strings.Contains(got, `"VisibilityTimeout":"45"`) { + t.Fatalf("SetQueueAttributes not applied: %s", got) + } + + // PurgeQueue drains messages. + postJSON(t, srv, "AmazonSQS.SendMessage", `{"QueueUrl":"`+qurl+`","MessageBody":"x"}`) + if resp := postJSON(t, srv, "AmazonSQS.PurgeQueue", + `{"QueueUrl":"`+qurl+`"}`); resp.StatusCode != http.StatusOK { + t.Fatalf("PurgeQueue status = %d", resp.StatusCode) + } + + got = readBody(t, postJSON(t, srv, "AmazonSQS.GetQueueAttributes", + `{"QueueUrl":"`+qurl+`","AttributeNames":["ApproximateNumberOfMessages"]}`)) + if !strings.Contains(got, `"ApproximateNumberOfMessages":"0"`) { + t.Fatalf("PurgeQueue left messages: %s", got) + } +} + +// TestQueueTagging is a regression guard for issue #319: TagQueue / +// UntagQueue / ListQueueTags were unimplemented (UnknownOperationException). +func TestQueueTagging(t *testing.T) { + srv, _ := newServer(t) + + create := postJSON(t, srv, "AmazonSQS.CreateQueue", `{"QueueName":"tq"}`) + qurl := extractQueueURL(t, create) + + if resp := postJSON(t, srv, "AmazonSQS.TagQueue", + `{"QueueUrl":"`+qurl+`","Tags":{"env":"prod","team":"msg"}}`); resp.StatusCode != http.StatusOK { + t.Fatalf("TagQueue status = %d", resp.StatusCode) + } + + got := readBody(t, postJSON(t, srv, "AmazonSQS.ListQueueTags", `{"QueueUrl":"`+qurl+`"}`)) + if !strings.Contains(got, `"env":"prod"`) || !strings.Contains(got, `"team":"msg"`) { + t.Fatalf("ListQueueTags = %s", got) + } + + if resp := postJSON(t, srv, "AmazonSQS.UntagQueue", + `{"QueueUrl":"`+qurl+`","TagKeys":["env"]}`); resp.StatusCode != http.StatusOK { + t.Fatalf("UntagQueue status = %d", resp.StatusCode) + } + + got = readBody(t, postJSON(t, srv, "AmazonSQS.ListQueueTags", `{"QueueUrl":"`+qurl+`"}`)) + if strings.Contains(got, `"env"`) || !strings.Contains(got, `"team":"msg"`) { + t.Fatalf("after untag = %s", got) + } +} + func postJSON(t *testing.T, srv *httptest.Server, target, body string) *http.Response { t.Helper() diff --git a/server/aws/ssm/handler.go b/server/aws/ssm/handler.go index 8866d64c..8ea1f3b3 100644 --- a/server/aws/ssm/handler.go +++ b/server/aws/ssm/handler.go @@ -64,6 +64,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.sendCommand(w, r) case "GetCommandInvocation": h.getCommandInvocation(w, r) + case "AddTagsToResource": + h.addTagsToResource(w, r) + case "RemoveTagsFromResource": + h.removeTagsFromResource(w, r) + case "ListTagsForResource": + h.listTagsForResource(w, r) default: wire.WriteJSONError(w, http.StatusBadRequest, "UnknownOperationException", "unknown SSM operation: "+op) diff --git a/server/aws/ssm/sdk_roundtrip_test.go b/server/aws/ssm/sdk_roundtrip_test.go index 45bc6413..e8e95003 100644 --- a/server/aws/ssm/sdk_roundtrip_test.go +++ b/server/aws/ssm/sdk_roundtrip_test.go @@ -78,6 +78,57 @@ func TestSDKPutGetParameter(t *testing.T) { } } +// TestSDKParameterTagging is a regression guard for issue #319: +// AddTagsToResource / RemoveTagsFromResource / ListTagsForResource were +// unimplemented (UnknownOperationException). +func TestSDKParameterTagging(t *testing.T) { + client := newSSMClient(t) + ctx := context.Background() + + if _, err := client.PutParameter(ctx, &awsssm.PutParameterInput{ + Name: aws.String("/app/db"), Value: aws.String("v"), Type: ssmtypes.ParameterTypeString, + }); err != nil { + t.Fatalf("PutParameter: %v", err) + } + + if _, err := client.AddTagsToResource(ctx, &awsssm.AddTagsToResourceInput{ + ResourceType: ssmtypes.ResourceTypeForTaggingParameter, + ResourceId: aws.String("/app/db"), + Tags: []ssmtypes.Tag{{Key: aws.String("env"), Value: aws.String("prod")}}, + }); err != nil { + t.Fatalf("AddTagsToResource: %v", err) + } + + list, err := client.ListTagsForResource(ctx, &awsssm.ListTagsForResourceInput{ + ResourceType: ssmtypes.ResourceTypeForTaggingParameter, ResourceId: aws.String("/app/db"), + }) + if err != nil { + t.Fatalf("ListTagsForResource: %v", err) + } + + if len(list.TagList) != 1 || aws.ToString(list.TagList[0].Key) != "env" { + t.Fatalf("TagList = %+v", list.TagList) + } + + if _, err := client.RemoveTagsFromResource(ctx, &awsssm.RemoveTagsFromResourceInput{ + ResourceType: ssmtypes.ResourceTypeForTaggingParameter, + ResourceId: aws.String("/app/db"), TagKeys: []string{"env"}, + }); err != nil { + t.Fatalf("RemoveTagsFromResource: %v", err) + } + + list, err = client.ListTagsForResource(ctx, &awsssm.ListTagsForResourceInput{ + ResourceType: ssmtypes.ResourceTypeForTaggingParameter, ResourceId: aws.String("/app/db"), + }) + if err != nil { + t.Fatalf("ListTagsForResource after remove: %v", err) + } + + if len(list.TagList) != 0 { + t.Fatalf("TagList after remove = %+v, want empty", list.TagList) + } +} + func TestSDKPutOverwriteVersioning(t *testing.T) { client := newSSMClient(t) ctx := context.Background() diff --git a/server/aws/ssm/tags.go b/server/aws/ssm/tags.go new file mode 100644 index 00000000..fd062ff9 --- /dev/null +++ b/server/aws/ssm/tags.go @@ -0,0 +1,107 @@ +package ssm + +import ( + "context" + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire" +) + +// parameterTagger is the AWS-specific parameter-tagging surface. It's not part +// of the portable ParameterStore driver, so the handler type-asserts for it. +type parameterTagger interface { + TagParameter(ctx context.Context, name string, tags map[string]string) error + UntagParameter(ctx context.Context, name string, keys []string) error + ListParameterTags(ctx context.Context, name string) (map[string]string, error) +} + +type ssmTag struct { + Key string `json:"Key"` + Value string `json:"Value"` +} + +func (h *Handler) addTagsToResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.store.(parameterTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceType string `json:"ResourceType"` + ResourceID string `json:"ResourceId"` + Tags []ssmTag `json:"Tags"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags := make(map[string]string, len(req.Tags)) + for _, t := range req.Tags { + tags[t.Key] = t.Value + } + + if err := tagger.TagParameter(r.Context(), req.ResourceID, tags); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) removeTagsFromResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.store.(parameterTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceType string `json:"ResourceType"` + ResourceID string `json:"ResourceId"` + TagKeys []string `json:"TagKeys"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + if err := tagger.UntagParameter(r.Context(), req.ResourceID, req.TagKeys); err != nil { + writeErr(w, err) + return + } + + wire.WriteJSON(w, struct{}{}) +} + +func (h *Handler) listTagsForResource(w http.ResponseWriter, r *http.Request) { + tagger, ok := h.store.(parameterTagger) + if !ok { + writeErr(w, cerrors.New(cerrors.Unimplemented, "tagging not supported")) + return + } + + var req struct { + ResourceType string `json:"ResourceType"` + ResourceID string `json:"ResourceId"` + } + + if !wire.DecodeJSON(w, r, &req) { + return + } + + tags, err := tagger.ListParameterTags(r.Context(), req.ResourceID) + if err != nil { + writeErr(w, err) + return + } + + out := make([]ssmTag, 0, len(tags)) + for k, v := range tags { + out = append(out, ssmTag{Key: k, Value: v}) + } + + wire.WriteJSON(w, map[string]any{"TagList": out}) +} diff --git a/server/azure/aks/operations.go b/server/azure/aks/operations.go index 975791f5..84f75618 100644 --- a/server/azure/aks/operations.go +++ b/server/azure/aks/operations.go @@ -35,6 +35,10 @@ func buildClusterInput(body *armManagedCluster, rp *azurearm.ResourcePath) aks.C Tags: fromPtrTags(body.Tags), } + if body.SKU != nil { + in.Tier = body.SKU.Tier + } + if body.Properties != nil { in.KubernetesVersion = body.Properties.KubernetesVersion in.DNSPrefix = body.Properties.DNSPrefix @@ -43,15 +47,16 @@ func buildClusterInput(body *armManagedCluster, rp *azurearm.ResourcePath) aks.C for i := range body.Properties.AgentPoolProfiles { p := &body.Properties.AgentPoolProfiles[i] in.AgentPools = append(in.AgentPools, aks.AgentPoolInput{ - Name: p.Name, - Count: p.Count, - VMSize: p.VMSize, - OSDiskSizeGB: p.OSDiskSizeGB, - OSType: p.OSType, - Mode: p.Mode, - OrchestratorVer: p.OrchestratorVer, - NodeLabels: fromPtrTags(p.NodeLabels), - NodeTaints: p.NodeTaints, + Name: p.Name, + Count: p.Count, + VMSize: p.VMSize, + OSDiskSizeGB: p.OSDiskSizeGB, + OSType: p.OSType, + Mode: p.Mode, + OrchestratorVer: p.OrchestratorVer, + ScaleSetPriority: p.ScaleSetPriority, + NodeLabels: fromPtrTags(p.NodeLabels), + NodeTaints: p.NodeTaints, }) } } @@ -141,6 +146,7 @@ func (h *Handler) createOrUpdateAgentPool(w http.ResponseWriter, r *http.Request in.OSType = body.Properties.OSType in.Mode = body.Properties.Mode in.OrchestratorVer = body.Properties.OrchestratorVer + in.ScaleSetPriority = body.Properties.ScaleSetPriority in.NodeLabels = fromPtrTags(body.Properties.NodeLabels) in.NodeTaints = body.Properties.NodeTaints } diff --git a/server/azure/aks/sdk_costfields_test.go b/server/azure/aks/sdk_costfields_test.go new file mode 100644 index 00000000..1ba0ef9e --- /dev/null +++ b/server/azure/aks/sdk_costfields_test.go @@ -0,0 +1,123 @@ +package aks_test + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v6" +) + +// TestSDKAKSCostFieldsRoundTrip asserts that the cost-sensitive inputs a +// discoverer reads — the cluster SKU tier and the agent-pool scale-set priority +// — survive a real armcontainerservice create/GET round-trip instead of being +// dropped and reset to the Free / Regular defaults. +func TestSDKAKSCostFieldsRoundTrip(t *testing.T) { + clusters, pools, _ := newSDKClients(t) + ctx := context.Background() + + // Create a cluster with sku.tier=Standard and an inline Spot pool. + cPoller, err := clusters.BeginCreateOrUpdate(ctx, "rg-1", "k8s-1", armcontainerservice.ManagedCluster{ + Location: to.Ptr("eastus"), + SKU: &armcontainerservice.ManagedClusterSKU{ + Name: to.Ptr(armcontainerservice.ManagedClusterSKUNameBase), + Tier: to.Ptr(armcontainerservice.ManagedClusterSKUTierStandard), + }, + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: []*armcontainerservice.ManagedClusterAgentPoolProfile{ + { + Name: to.Ptr("spotinline"), + Count: to.Ptr[int32](2), + VMSize: to.Ptr("Standard_DS2_v2"), + Mode: to.Ptr(armcontainerservice.AgentPoolModeUser), + ScaleSetPriority: to.Ptr(armcontainerservice.ScaleSetPrioritySpot), + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("Cluster BeginCreateOrUpdate: %v", err) + } + + createResp, err := cPoller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("Cluster PollUntilDone: %v", err) + } + + // The create response echoes the tier back (not the Free default). + if createResp.SKU == nil || createResp.SKU.Tier == nil || + *createResp.SKU.Tier != armcontainerservice.ManagedClusterSKUTierStandard { + t.Fatalf("create: got sku.tier %v, want Standard", skuTier(createResp.ManagedCluster)) + } + + // GET reads the tier back. + got, err := clusters.Get(ctx, "rg-1", "k8s-1", nil) + if err != nil { + t.Fatalf("Cluster Get: %v", err) + } + + if got.SKU == nil || got.SKU.Tier == nil || + *got.SKU.Tier != armcontainerservice.ManagedClusterSKUTierStandard { + t.Fatalf("get: got sku.tier %v, want Standard", skuTier(got.ManagedCluster)) + } + + // The inline pool carries scaleSetPriority=Spot (not the Regular default). + inlinePool, err := pools.Get(ctx, "rg-1", "k8s-1", "spotinline", nil) + if err != nil { + t.Fatalf("inline pool Get: %v", err) + } + + if p := inlinePool.Properties; p == nil || p.ScaleSetPriority == nil || + *p.ScaleSetPriority != armcontainerservice.ScaleSetPrioritySpot { + t.Fatalf("inline pool: got scaleSetPriority %v, want Spot", poolPriority(inlinePool.AgentPool)) + } + + // A standalone agent-pool create also carries scaleSetPriority through. + poolPoller, err := pools.BeginCreateOrUpdate(ctx, "rg-1", "k8s-1", "spotpool", armcontainerservice.AgentPool{ + Properties: &armcontainerservice.ManagedClusterAgentPoolProfileProperties{ + Count: to.Ptr[int32](3), + VMSize: to.Ptr("Standard_D4s_v3"), + Mode: to.Ptr(armcontainerservice.AgentPoolModeUser), + ScaleSetPriority: to.Ptr(armcontainerservice.ScaleSetPrioritySpot), + }, + }, nil) + if err != nil { + t.Fatalf("Pool BeginCreateOrUpdate: %v", err) + } + + poolResp, err := poolPoller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("Pool PollUntilDone: %v", err) + } + + if p := poolResp.Properties; p == nil || p.ScaleSetPriority == nil || + *p.ScaleSetPriority != armcontainerservice.ScaleSetPrioritySpot { + t.Fatalf("create pool: got scaleSetPriority %v, want Spot", poolPriority(poolResp.AgentPool)) + } + + gotPool, err := pools.Get(ctx, "rg-1", "k8s-1", "spotpool", nil) + if err != nil { + t.Fatalf("Pool Get: %v", err) + } + + if p := gotPool.Properties; p == nil || p.ScaleSetPriority == nil || + *p.ScaleSetPriority != armcontainerservice.ScaleSetPrioritySpot { + t.Fatalf("get pool: got scaleSetPriority %v, want Spot", poolPriority(gotPool.AgentPool)) + } +} + +func skuTier(c armcontainerservice.ManagedCluster) any { + if c.SKU == nil || c.SKU.Tier == nil { + return nil + } + + return *c.SKU.Tier +} + +func poolPriority(p armcontainerservice.AgentPool) any { + if p.Properties == nil || p.Properties.ScaleSetPriority == nil { + return nil + } + + return *p.Properties.ScaleSetPriority +} diff --git a/server/azure/aks/types.go b/server/azure/aks/types.go index 93164f95..99a4188d 100644 --- a/server/azure/aks/types.go +++ b/server/azure/aks/types.go @@ -22,9 +22,17 @@ type armManagedCluster struct { Type string `json:"type,omitempty"` Location string `json:"location,omitempty"` Tags map[string]*string `json:"tags,omitempty"` + SKU *armManagedClusterSKU `json:"sku,omitempty"` Properties *armManagedClusterProperties `json:"properties,omitempty"` } +// armManagedClusterSKU mirrors armcontainerservice.ManagedClusterSKU. The tier +// (Free / Standard / Premium) is the uptime-SLA cost input a discoverer reads. +type armManagedClusterSKU struct { + Name string `json:"name,omitempty"` + Tier string `json:"tier,omitempty"` +} + type armManagedClusterProperties struct { ProvisioningState string `json:"provisioningState,omitempty"` KubernetesVersion string `json:"kubernetesVersion,omitempty"` @@ -48,6 +56,7 @@ type armAgentPoolProfile struct { OSType string `json:"osType,omitempty"` Mode string `json:"mode,omitempty"` OrchestratorVer string `json:"orchestratorVersion,omitempty"` + ScaleSetPriority string `json:"scaleSetPriority,omitempty"` NodeLabels map[string]*string `json:"nodeLabels,omitempty"` NodeTaints []string `json:"nodeTaints,omitempty"` ProvisioningState string `json:"provisioningState,omitempty"` @@ -70,6 +79,7 @@ type armAgentPoolProperties struct { OSType string `json:"osType,omitempty"` Mode string `json:"mode,omitempty"` OrchestratorVer string `json:"orchestratorVersion,omitempty"` + ScaleSetPriority string `json:"scaleSetPriority,omitempty"` NodeLabels map[string]*string `json:"nodeLabels,omitempty"` NodeTaints []string `json:"nodeTaints,omitempty"` ProvisioningState string `json:"provisioningState,omitempty"` @@ -116,6 +126,7 @@ func toARMCluster(c *aks.ManagedCluster, pools []aks.AgentPool, subscription str Type: resourceTypeManagedClusterFull, Location: c.Location, Tags: toPtrTags(c.Tags), + SKU: &armManagedClusterSKU{Name: "Base", Tier: c.Tier}, Properties: &armManagedClusterProperties{ ProvisioningState: c.ProvisioningState, KubernetesVersion: c.KubernetesVersion, @@ -143,6 +154,7 @@ func toAgentPoolProfiles(pools []aks.AgentPool) []armAgentPoolProfile { OSType: pools[i].OSType, Mode: pools[i].Mode, OrchestratorVer: pools[i].OrchestratorVer, + ScaleSetPriority: pools[i].ScaleSetPriority, NodeLabels: toPtrTags(pools[i].NodeLabels), NodeTaints: pools[i].NodeTaints, ProvisioningState: pools[i].ProvisioningState, @@ -166,6 +178,7 @@ func toARMAgentPool(p *aks.AgentPool, subscription string) armAgentPool { OSType: p.OSType, Mode: p.Mode, OrchestratorVer: p.OrchestratorVer, + ScaleSetPriority: p.ScaleSetPriority, NodeLabels: toPtrTags(p.NodeLabels), NodeTaints: p.NodeTaints, ProvisioningState: p.ProvisioningState, diff --git a/server/azure/azure.go b/server/azure/azure.go index ca8d71ca..7c57abbb 100644 --- a/server/azure/azure.go +++ b/server/azure/azure.go @@ -16,6 +16,8 @@ import ( "github.com/stackshy/cloudemu/v2/server/azure/blob" cachesrv "github.com/stackshy/cloudemu/v2/server/azure/cache" "github.com/stackshy/cloudemu/v2/server/azure/cosmos" + "github.com/stackshy/cloudemu/v2/server/azure/cosmosaccount" + "github.com/stackshy/cloudemu/v2/server/azure/cosmospostgresql" "github.com/stackshy/cloudemu/v2/server/azure/databricks" "github.com/stackshy/cloudemu/v2/server/azure/databricks/dbfs" "github.com/stackshy/cloudemu/v2/server/azure/databricks/gitcredentials" @@ -52,6 +54,7 @@ import ( "github.com/stackshy/cloudemu/v2/server/azure/servicebus" "github.com/stackshy/cloudemu/v2/server/azure/snapshots" "github.com/stackshy/cloudemu/v2/server/azure/sshpublickeys" + storageaccountsrv "github.com/stackshy/cloudemu/v2/server/azure/storageaccount" "github.com/stackshy/cloudemu/v2/server/azure/subscriptions" tablesrv "github.com/stackshy/cloudemu/v2/server/azure/table" "github.com/stackshy/cloudemu/v2/server/azure/virtualmachines" @@ -60,6 +63,7 @@ import ( cachedriver "github.com/stackshy/cloudemu/v2/services/cache/driver" computedriver "github.com/stackshy/cloudemu/v2/services/compute/driver" crdriver "github.com/stackshy/cloudemu/v2/services/containerregistry/driver" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" dbxdriver "github.com/stackshy/cloudemu/v2/services/databricks/driver" dnsdriver "github.com/stackshy/cloudemu/v2/services/dns/driver" @@ -105,6 +109,9 @@ type Drivers struct { // ManagedCassandra serves Microsoft.DocumentDB/cassandraClusters (Azure // Managed Instance for Apache Cassandra) via the ARM protocol. ManagedCassandra mcdriver.ManagedCassandra + // CosmosPostgreSQL serves Microsoft.DBforPostgreSQL/serverGroupsv2 (Azure + // Cosmos DB for PostgreSQL, Citus) via the ARM protocol. + CosmosPostgreSQL cpgdriver.CosmosPostgreSQL Network netdriver.Networking Monitor mondriver.Monitoring Functions sdrv.Serverless @@ -201,6 +208,11 @@ func New(d Drivers) *server.Server { // blob handler. if d.CosmosDB != nil { srv.Register(cosmos.New(d.CosmosDB)) + // Cosmos-account ARM control plane (Microsoft.DocumentDB/databaseAccounts). + // Claims only the /providers/Microsoft.DocumentDB/databaseAccounts/ + // management path — disjoint from the /dbs data plane above and from + // managedcassandra (cassandraClusters), so order is unconstrained. + srv.Register(cosmosaccount.New(d.CosmosDB)) } // Managed Cassandra matches ARM Microsoft.DocumentDB/cassandraClusters paths @@ -209,6 +221,10 @@ func New(d Drivers) *server.Server { srv.Register(managedcassandra.New(d.ManagedCassandra)) } + if d.CosmosPostgreSQL != nil { + srv.Register(cosmospostgresql.New(d.CosmosPostgreSQL)) + } + if d.Network != nil { srv.Register(network.New(d.Network)) } @@ -389,6 +405,14 @@ func New(d Drivers) *server.Server { srv.Register(queue.New(d.QueueStorage)) } + // Storage-account ARM control plane (Microsoft.Storage/storageAccounts). + // Claims only the /providers/Microsoft.Storage/storageAccounts/ management + // path (which starts with /subscriptions/), disjoint from the blob + // data-plane fallback below, so it must register before that fallback. + if d.BlobStorage != nil { + srv.Register(storageaccountsrv.New(d.BlobStorage)) + } + // BlobStorage handler is the data-plane fallback for non-ARM URLs. It // must register last so its permissive Matches() doesn't shadow the // ARM-specific resource handlers. diff --git a/server/azure/azuresql/cost_fields_sdk_test.go b/server/azure/azuresql/cost_fields_sdk_test.go new file mode 100644 index 00000000..8fe0f1b5 --- /dev/null +++ b/server/azure/azuresql/cost_fields_sdk_test.go @@ -0,0 +1,108 @@ +package azuresql_test + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql" +) + +// TestSDKAzureSQLManagedInstanceBackupRedundancy verifies the backup storage +// redundancy the armsql SDK sends as requestedBackupStorageRedundancy survives +// a managed-instance create → get round-trip and is echoed back in the read +// form (currentBackupStorageRedundancy) the SDK deserializes. +func TestSDKAzureSQLManagedInstanceBackupRedundancy(t *testing.T) { + cf := newFactory(t) + ctx := context.Background() + mic := cf.NewManagedInstancesClient() + + poller, err := mic.BeginCreateOrUpdate(ctx, "rg-1", "mi1", armsql.ManagedInstance{ + Location: to.Ptr("eastus"), + Properties: &armsql.ManagedInstanceProperties{ + AdministratorLogin: to.Ptr("miadmin"), + VCores: to.Ptr(int32(4)), + SubnetID: to.Ptr("/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/vn/subnets/mi"), + RequestedBackupStorageRedundancy: to.Ptr(armsql.BackupStorageRedundancyZone), + }, + }, nil) + if err != nil { + t.Fatalf("MI create: %v", err) + } + + createResp, err := poller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("MI create poll: %v", err) + } + + if got := createResp.Properties.CurrentBackupStorageRedundancy; got == nil || *got != armsql.BackupStorageRedundancyZone { + t.Fatalf("create: currentBackupStorageRedundancy = %v, want Zone", got) + } + + got, err := mic.Get(ctx, "rg-1", "mi1", nil) + if err != nil { + t.Fatalf("MI Get: %v", err) + } + + if v := got.Properties.CurrentBackupStorageRedundancy; v == nil || *v != armsql.BackupStorageRedundancyZone { + t.Fatalf("get: currentBackupStorageRedundancy = %v, want Zone", v) + } + + if v := got.Properties.RequestedBackupStorageRedundancy; v == nil || *v != armsql.BackupStorageRedundancyZone { + t.Fatalf("get: requestedBackupStorageRedundancy = %v, want Zone", v) + } +} + +// TestSDKAzureSQLDatabaseCostFields verifies a database's SKU (name + tier) and +// the zoneRedundant HA flag survive create → get through the armsql SDK, backed +// by the Databases capability so the same record is discoverable in Resource +// Graph. +func TestSDKAzureSQLDatabaseCostFields(t *testing.T) { + cf := newFactory(t) + mustCreateSQLServer(t, cf) + + ctx := context.Background() + dbs := cf.NewDatabasesClient() + + poller, err := dbs.BeginCreateOrUpdate(ctx, "rg-1", "srv1", "appdb", armsql.Database{ + Location: to.Ptr("eastus"), + SKU: &armsql.SKU{Name: to.Ptr("GP_Gen5_4"), Tier: to.Ptr("GeneralPurpose")}, + Properties: &armsql.DatabaseProperties{ + ZoneRedundant: to.Ptr(true), + }, + }, nil) + if err != nil { + t.Fatalf("DB create: %v", err) + } + + createResp, err := poller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("DB create poll: %v", err) + } + + if createResp.SKU == nil || createResp.SKU.Name == nil || *createResp.SKU.Name != "GP_Gen5_4" { + t.Fatalf("create: sku.name = %v, want GP_Gen5_4", createResp.SKU) + } + + got, err := dbs.Get(ctx, "rg-1", "srv1", "appdb", nil) + if err != nil { + t.Fatalf("DB Get: %v", err) + } + + if got.SKU == nil || got.SKU.Name == nil || *got.SKU.Name != "GP_Gen5_4" { + t.Fatalf("get: sku.name = %v, want GP_Gen5_4", got.SKU) + } + + if got.SKU.Tier == nil || *got.SKU.Tier != "GeneralPurpose" { + t.Fatalf("get: sku.tier = %v, want GeneralPurpose", got.SKU.Tier) + } + + if got.Properties == nil || got.Properties.CurrentSKU == nil || + got.Properties.CurrentSKU.Name == nil || *got.Properties.CurrentSKU.Name != "GP_Gen5_4" { + t.Fatalf("get: properties.currentSku.name = %v, want GP_Gen5_4", got.Properties) + } + + if got.Properties.ZoneRedundant == nil || !*got.Properties.ZoneRedundant { + t.Fatalf("get: properties.zoneRedundant = %v, want true", got.Properties.ZoneRedundant) + } +} diff --git a/server/azure/azuresql/handler.go b/server/azure/azuresql/handler.go index cd10ee45..eb357bee 100644 --- a/server/azure/azuresql/handler.go +++ b/server/azure/azuresql/handler.go @@ -145,6 +145,12 @@ func (h *Handler) serveServerCollection(w http.ResponseWriter, r *http.Request, } func (h *Handler) serveDatabaseRoute(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + db, ok := h.databases() + if !ok { + writeUnsupported(w, "databases") + return + } + // rp.ResourceName is the server name; rp.SubResourceName is the database // name (or empty for the collection). if rp.SubResourceName == "" { @@ -153,20 +159,18 @@ func (h *Handler) serveDatabaseRoute(w http.ResponseWriter, r *http.Request, rp return } - h.listDatabases(w, r, rp) + h.listDatabases(w, r, rp, db) return } switch r.Method { - case http.MethodPut: - h.createOrUpdateDatabase(w, r, rp) - case http.MethodPatch: - h.updateDatabase(w, r, rp) + case http.MethodPut, http.MethodPatch: + h.putDatabase(w, r, rp, db) case http.MethodGet: - h.getDatabase(w, r, rp) + h.getDatabase(w, r, rp, db) case http.MethodDelete: - h.deleteDatabase(w, r, rp) + h.deleteDatabase(w, r, rp, db) default: writeMethodNotAllowed(w) } diff --git a/server/azure/azuresql/managedinstance.go b/server/azure/azuresql/managedinstance.go index e8c95cce..f8ea27b4 100644 --- a/server/azure/azuresql/managedinstance.go +++ b/server/azure/azuresql/managedinstance.go @@ -21,13 +21,20 @@ type armManagedInstance struct { } type armManagedInstanceCfg struct { - AdministratorLogin string `json:"administratorLogin,omitempty"` - VCores int `json:"vCores,omitempty"` - StorageSizeInGB int `json:"storageSizeInGB,omitempty"` - LicenseType string `json:"licenseType,omitempty"` - SubnetID string `json:"subnetId,omitempty"` - State string `json:"state,omitempty"` - FullyQualifiedDomainName string `json:"fullyQualifiedDomainName,omitempty"` + AdministratorLogin string `json:"administratorLogin,omitempty"` + VCores int `json:"vCores,omitempty"` + StorageSizeInGB int `json:"storageSizeInGB,omitempty"` + LicenseType string `json:"licenseType,omitempty"` + SubnetID string `json:"subnetId,omitempty"` + // RequestedBackupStorageRedundancy is the write field the armsql SDK sends + // (Geo/GeoZone/Local/Zone). StorageAccountType is the CloudEmu read echo in + // the driver's normalized form (…Redundant); CurrentBackupStorageRedundancy + // echoes the enum form the SDK reads back. + RequestedBackupStorageRedundancy string `json:"requestedBackupStorageRedundancy,omitempty"` + StorageAccountType string `json:"storageAccountType,omitempty"` + CurrentBackupStorageRedundancy string `json:"currentBackupStorageRedundancy,omitempty"` + State string `json:"state,omitempty"` + FullyQualifiedDomainName string `json:"fullyQualifiedDomainName,omitempty"` } type armManagedDatabase struct { @@ -108,11 +115,60 @@ func miCfgFromBody(body *armManagedInstance, rp *azurearm.ResourcePath) rdsdrive cfg.StorageGB = body.Properties.StorageSizeInGB cfg.LicenseType = body.Properties.LicenseType cfg.SubnetID = body.Properties.SubnetID + cfg.StorageAccountType = normalizeBackupRedundancy(body.Properties.RequestedBackupStorageRedundancy) } return cfg } +// Backup storage redundancy: the armsql SDK enum (…) and the driver's stored +// read form (…Redundant). +const ( + backupGeo = "Geo" + backupGeoRedundant = "GeoRedundant" + backupGeoZone = "GeoZone" + backupGeoZoneRedundant = "GeoZoneRedundant" + backupLocal = "Local" + backupLocalRedundant = "LocalRedundant" + backupZone = "Zone" + backupZoneRedundant = "ZoneRedundant" +) + +// normalizeBackupRedundancy maps the armsql requestedBackupStorageRedundancy +// enum (Geo/GeoZone/Local/Zone) to the driver's read form (…Redundant). An +// empty/unknown value returns "" so the provider applies its own default. +func normalizeBackupRedundancy(v string) string { + switch v { + case backupGeo: + return backupGeoRedundant + case backupGeoZone: + return backupGeoZoneRedundant + case backupLocal: + return backupLocalRedundant + case backupZone: + return backupZoneRedundant + default: + return "" + } +} + +// backupRedundancyEnum reverses normalizeBackupRedundancy so the armsql SDK, +// which reads currentBackupStorageRedundancy, observes the stored redundancy. +func backupRedundancyEnum(v string) string { + switch v { + case backupGeoRedundant: + return backupGeo + case backupGeoZoneRedundant: + return backupGeoZone + case backupLocalRedundant: + return backupLocal + case backupZoneRedundant: + return backupZone + default: + return "" + } +} + func (*Handler) putManagedInstance( w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, mi rdsdriver.ManagedInstances, ) { @@ -228,13 +284,16 @@ func toARMManagedInstance(mi *rdsdriver.ManagedInstance, rp *azurearm.ResourcePa Tags: mi.Tags, SKU: &armSKU{Name: mi.SKUName, Tier: mi.SKUTier}, Properties: &armManagedInstanceCfg{ - AdministratorLogin: mi.AdminLogin, - VCores: mi.VCores, - StorageSizeInGB: mi.StorageGB, - LicenseType: mi.LicenseType, - SubnetID: mi.SubnetID, - State: mi.State, - FullyQualifiedDomainName: mi.FQDN, + AdministratorLogin: mi.AdminLogin, + VCores: mi.VCores, + StorageSizeInGB: mi.StorageGB, + LicenseType: mi.LicenseType, + SubnetID: mi.SubnetID, + StorageAccountType: mi.StorageAccountType, + RequestedBackupStorageRedundancy: backupRedundancyEnum(mi.StorageAccountType), + CurrentBackupStorageRedundancy: backupRedundancyEnum(mi.StorageAccountType), + State: mi.State, + FullyQualifiedDomainName: mi.FQDN, }, } } diff --git a/server/azure/azuresql/operations.go b/server/azure/azuresql/operations.go index b049ea8d..befc5974 100644 --- a/server/azure/azuresql/operations.go +++ b/server/azure/azuresql/operations.go @@ -1,6 +1,7 @@ package azuresql import ( + "context" "net/http" cerrors "github.com/stackshy/cloudemu/v2/errors" @@ -108,131 +109,120 @@ func (h *Handler) listServers(w http.ResponseWriter, r *http.Request, rp *azurea azurearm.WriteJSON(w, http.StatusOK, armList[armServer]{Value: out}) } -// ---- Database ops ---- +// ---- Database ops (Databases capability) ---- -//nolint:gocyclo // sequential field defaulting + restore path keeps the body linear. -func (h *Handler) createOrUpdateDatabase(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { - var body armDatabase - if !azurearm.DecodeJSON(w, r, &body) { - return - } - - server := rp.ResourceName - dbName := rp.SubResourceName - - // Restore path: createMode=Restore + sourceDatabaseId. - if body.Properties != nil && body.Properties.CreateMode == "Restore" { - input := rdsdriver.RestoreInstanceInput{ - NewInstanceID: server + "/" + dbName, - SnapshotID: body.Properties.SourceDatabaseID, - } - - if body.SKU != nil { - input.InstanceClass = body.SKU.Name - } - - inst, err := h.db.RestoreInstanceFromSnapshot(r.Context(), input) - if err != nil { - azurearm.WriteCErr(w, err) - return - } - - azurearm.WriteJSON(w, http.StatusOK, toARMDatabase(inst, rp.Subscription, rp.ResourceGroup)) - - return - } - - cfg := rdsdriver.InstanceConfig{ - ID: dbName, - ClusterID: server, - Engine: "SQLServer", - AvailabilityZone: body.Location, - Tags: body.Tags, - } +// databases returns the optional Databases capability. Logical Azure SQL +// databases are backed by this capability (not the RDS instance path) so a +// database created over the wire is the same record Resource Graph enumerates +// via ListDatabases. +func (h *Handler) databases() (rdsdriver.Databases, bool) { + d, ok := h.db.(rdsdriver.Databases) + return d, ok +} +func dbCfgFromBody(body *armDatabase, rp *azurearm.ResourcePath) rdsdriver.DatabaseConfig { + cfg := rdsdriver.DatabaseConfig{Server: rp.ResourceName, Name: rp.SubResourceName} if body.SKU != nil { - cfg.InstanceClass = body.SKU.Name + cfg.SKUName = body.SKU.Name + cfg.SKUTier = body.SKU.Tier } if body.Properties != nil { - if body.Properties.MaxSizeBytes > 0 { - cfg.AllocatedStorage = int(body.Properties.MaxSizeBytes / (1 << 30)) + cfg.Collation = body.Properties.Collation + if body.Properties.ZoneRedundant != nil { + cfg.ZoneRedundant = *body.Properties.ZoneRedundant } + } + + return cfg +} - cfg.ElasticPoolID = body.Properties.ElasticPoolID +// putDatabase serves both PUT (CreateOrUpdate) and PATCH (Update): create when +// absent, otherwise apply the body's sku/tier/zoneRedundant to the existing +// record. The Databases capability has no update verb, so an upsert merges the +// body over the stored fields and re-creates. +func (*Handler) putDatabase(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, db rdsdriver.Databases) { + var body armDatabase + if !azurearm.DecodeJSON(w, r, &body) { + return } - inst, err := h.db.CreateInstance(r.Context(), cfg) + cfg := dbCfgFromBody(&body, rp) + + out, err := db.CreateDatabase(r.Context(), cfg) if err != nil { if !cerrors.IsAlreadyExists(err) { azurearm.WriteCErr(w, err) return } - // Upsert: PUT on an existing database applies the body (SKU/maxSize/tags). - inst, err = h.db.ModifyInstance(r.Context(), server+"/"+dbName, rdsdriver.ModifyInstanceInput{ - InstanceClass: cfg.InstanceClass, - AllocatedStorage: cfg.AllocatedStorage, - ElasticPoolID: cfg.ElasticPoolID, - Tags: body.Tags, - }) + out, err = replaceDatabase(r.Context(), db, &body, &cfg) if err != nil { azurearm.WriteCErr(w, err) return } } - azurearm.WriteJSON(w, http.StatusOK, toARMDatabase(inst, rp.Subscription, rp.ResourceGroup)) + azurearm.WriteJSON(w, http.StatusOK, toARMDatabase(out, rp)) } -func (h *Handler) updateDatabase(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { - var body armDatabase - if !azurearm.DecodeJSON(w, r, &body) { - return +// replaceDatabase merges the request body over the stored database and +// re-creates it, so a PUT/PATCH against an existing database changes sku/tier/ +// HA while leaving omitted fields intact. +func replaceDatabase( + ctx context.Context, db rdsdriver.Databases, body *armDatabase, cfg *rdsdriver.DatabaseConfig, +) (*rdsdriver.Database, error) { + existing, err := db.GetDatabase(ctx, cfg.Server, cfg.Name) + if err != nil { + return nil, err } - input := rdsdriver.ModifyInstanceInput{ - Tags: body.Tags, + merged := *existing + if cfg.SKUName != "" { + merged.SKUName = cfg.SKUName } - if body.SKU != nil { - input.InstanceClass = body.SKU.Name + if cfg.SKUTier != "" { + merged.SKUTier = cfg.SKUTier } - if body.Properties != nil { - if body.Properties.MaxSizeBytes > 0 { - input.AllocatedStorage = int(body.Properties.MaxSizeBytes / (1 << 30)) - } + if cfg.Collation != "" { + merged.Collation = cfg.Collation + } - input.ElasticPoolID = body.Properties.ElasticPoolID + if body.Properties != nil && body.Properties.ZoneRedundant != nil { + merged.ZoneRedundant = *body.Properties.ZoneRedundant } - inst, err := h.db.ModifyInstance(r.Context(), rp.ResourceName+"/"+rp.SubResourceName, input) - if err != nil { - azurearm.WriteCErr(w, err) - return + if err := db.DeleteDatabase(ctx, cfg.Server, cfg.Name); err != nil { + return nil, err } - azurearm.WriteJSON(w, http.StatusOK, toARMDatabase(inst, rp.Subscription, rp.ResourceGroup)) + return db.CreateDatabase(ctx, rdsdriver.DatabaseConfig{ + Server: merged.Server, + Name: merged.Name, + Charset: merged.Charset, + Collation: merged.Collation, + SKUName: merged.SKUName, + SKUTier: merged.SKUTier, + ZoneRedundant: merged.ZoneRedundant, + }) } -func (h *Handler) getDatabase(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { - insts, err := h.db.DescribeInstances(r.Context(), []string{rp.ResourceName + "/" + rp.SubResourceName}) +func (*Handler) getDatabase(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, db rdsdriver.Databases) { + out, err := db.GetDatabase(r.Context(), rp.ResourceName, rp.SubResourceName) if err != nil { azurearm.WriteCErr(w, err) return } - if len(insts) == 0 { - azurearm.WriteError(w, http.StatusNotFound, "ResourceNotFound", "database "+rp.SubResourceName+" not found") - return - } - - azurearm.WriteJSON(w, http.StatusOK, toARMDatabase(&insts[0], rp.Subscription, rp.ResourceGroup)) + azurearm.WriteJSON(w, http.StatusOK, toARMDatabase(out, rp)) } -func (h *Handler) deleteDatabase(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { - if err := h.db.DeleteInstance(r.Context(), rp.ResourceName+"/"+rp.SubResourceName); err != nil { +func (*Handler) deleteDatabase( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, db rdsdriver.Databases, +) { + if err := db.DeleteDatabase(r.Context(), rp.ResourceName, rp.SubResourceName); err != nil { azurearm.WriteCErr(w, err) return } @@ -240,21 +230,18 @@ func (h *Handler) deleteDatabase(w http.ResponseWriter, r *http.Request, rp *azu w.WriteHeader(http.StatusOK) } -func (h *Handler) listDatabases(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { - all, err := h.db.DescribeInstances(r.Context(), nil) +func (*Handler) listDatabases( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, db rdsdriver.Databases, +) { + items, err := db.ListDatabases(r.Context(), rp.ResourceName) if err != nil { azurearm.WriteCErr(w, err) return } - out := make([]armDatabase, 0) - - for i := range all { - if all[i].ClusterID != rp.ResourceName { - continue - } - - out = append(out, toARMDatabase(&all[i], rp.Subscription, rp.ResourceGroup)) + out := make([]armDatabase, 0, len(items)) + for i := range items { + out = append(out, toARMDatabase(&items[i], rp)) } azurearm.WriteJSON(w, http.StatusOK, armList[armDatabase]{Value: out}) diff --git a/server/azure/azuresql/types.go b/server/azure/azuresql/types.go index 1556f9ad..bc645b4d 100644 --- a/server/azure/azuresql/types.go +++ b/server/azure/azuresql/types.go @@ -1,16 +1,13 @@ package azuresql import ( + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" ) -// Azure SQL Database.status enum values used in ARM responses. -const ( - dbStatusOnline = "Online" - dbStatusPaused = "Paused" - dbStatusCreating = "Creating" - dbStatusDeleting = "Deleting" -) +// dbStatusOnline is the Azure SQL Database.status value echoed on read. Logical +// databases are always-on, so read responses report Online. +const dbStatusOnline = "Online" // armServer is the JSON shape Azure ARM expects for Microsoft.Sql/servers. type armServer struct { @@ -49,15 +46,17 @@ type armSKU struct { } type armDatabaseProps struct { - Status string `json:"status,omitempty"` - CreateMode string `json:"createMode,omitempty"` - SourceDatabaseID string `json:"sourceDatabaseId,omitempty"` - RestorePointInTime string `json:"restorePointInTime,omitempty"` - MaxSizeBytes int64 `json:"maxSizeBytes,omitempty"` - Collation string `json:"collation,omitempty"` - DatabaseID string `json:"databaseId,omitempty"` - CurrentServiceObjectiveName string `json:"currentServiceObjectiveName,omitempty"` - ElasticPoolID string `json:"elasticPoolId,omitempty"` + Status string `json:"status,omitempty"` + CreateMode string `json:"createMode,omitempty"` + SourceDatabaseID string `json:"sourceDatabaseId,omitempty"` + RestorePointInTime string `json:"restorePointInTime,omitempty"` + MaxSizeBytes int64 `json:"maxSizeBytes,omitempty"` + Collation string `json:"collation,omitempty"` + DatabaseID string `json:"databaseId,omitempty"` + CurrentServiceObjectiveName string `json:"currentServiceObjectiveName,omitempty"` + CurrentSKU *armSKU `json:"currentSku,omitempty"` + ZoneRedundant *bool `json:"zoneRedundant,omitempty"` + ElasticPoolID string `json:"elasticPoolId,omitempty"` } // armList is the ARM list-response envelope. @@ -84,24 +83,24 @@ func toARMServer(cluster *rdsdriver.Cluster, subscription, resourceGroup string) } } -// toARMDatabase converts a portable Instance (database) to ARM JSON. -func toARMDatabase(inst *rdsdriver.Instance, subscription, resourceGroup string) armDatabase { +// toARMDatabase converts a portable Database (Databases capability) to ARM JSON. +// SKU.name plus properties.currentSku / zoneRedundant are echoed so SKU/tier +// and HA are observable to both the armsql SDK and Resource Graph discovery. +func toARMDatabase(db *rdsdriver.Database, rp *azurearm.ResourcePath) armDatabase { + zoneRedundant := db.ZoneRedundant + return armDatabase{ - ID: armDatabaseID(subscription, resourceGroup, inst.ClusterID, inst.ID), - Name: inst.ID, - Type: providerName + "/servers/databases", - Location: inst.AvailabilityZone, - Tags: inst.Tags, - SKU: &armSKU{ - Name: inst.InstanceClass, - }, + ID: armDatabaseID(rp.Subscription, rp.ResourceGroup, db.Server, db.Name), + Name: db.Name, + Type: providerName + "/servers/databases", + SKU: &armSKU{Name: db.SKUName, Tier: db.SKUTier}, Properties: &armDatabaseProps{ - Status: databaseStatus(inst.State), - MaxSizeBytes: int64(inst.AllocatedStorage) * (1 << 30), - Collation: "SQL_Latin1_General_CP1_CI_AS", - DatabaseID: inst.ARN, - CurrentServiceObjectiveName: inst.InstanceClass, - ElasticPoolID: inst.ElasticPoolID, + Status: dbStatusOnline, + Collation: db.Collation, + DatabaseID: db.ARN, + CurrentServiceObjectiveName: db.SKUName, + CurrentSKU: &armSKU{Name: db.SKUName, Tier: db.SKUTier}, + ZoneRedundant: &zoneRedundant, }, } } @@ -115,20 +114,3 @@ func armServerID(subscription, resourceGroup, server string) string { func armDatabaseID(subscription, resourceGroup, server, database string) string { return armServerID(subscription, resourceGroup, server) + "/databases/" + database } - -// databaseStatus maps the portable lifecycle to the Azure SQL Database.status -// enum (Online, Offline, Restoring, Creating, Disabled). -func databaseStatus(state string) string { - switch state { - case rdsdriver.StateAvailable: - return dbStatusOnline - case rdsdriver.StateStopped: - return dbStatusPaused - case rdsdriver.StateCreating: - return dbStatusCreating - case rdsdriver.StateDeleting: - return dbStatusDeleting - default: - return dbStatusOnline - } -} diff --git a/server/azure/cosmosaccount/handler.go b/server/azure/cosmosaccount/handler.go new file mode 100644 index 00000000..7a00f936 --- /dev/null +++ b/server/azure/cosmosaccount/handler.go @@ -0,0 +1,212 @@ +// Package cosmosaccount implements the Azure Cosmos DB account ARM control +// plane (Microsoft.DocumentDB/databaseAccounts) as a server.Handler. Real +// github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos +// DatabaseAccountsClient clients configured with a custom endpoint hit this +// handler the same way they hit management.azure.com. +// +// This is the management-plane counterpart to the cosmos SQL data-plane +// handler: an account name maps to a driver table, and the account's kind / +// offer-type / free-tier / capabilities cost attributes are stored via the +// driver's optional TableAttributes capability so a discovery + cost consumer +// can price it. +// +// Coverage: +// +// PUT .../providers/Microsoft.DocumentDB/databaseAccounts/{name} — create/update +// GET .../providers/Microsoft.DocumentDB/databaseAccounts/{name} — get +// DELETE .../providers/Microsoft.DocumentDB/databaseAccounts/{name} — delete +// +// Create is a long-running operation in real Azure; the emulator completes it +// synchronously by returning 200 with the resource body inline so the SDK's LRO +// poller terminates on the first response. +package cosmosaccount + +import ( + "context" + "net/http" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" +) + +const ( + providerName = "Microsoft.DocumentDB" + resourceType = "databaseAccounts" + defaultLocation = "eastus" +) + +// attrBackend is the optional Cosmos-account attribute capability. The Azure +// cosmos mock implements it; DynamoDB/Firestore don't. +type attrBackend interface { + SetTableAttributes(table string, attrs dbdriver.AccountAttributes) + TableAttributes(ctx context.Context, table string) (dbdriver.AccountAttributes, error) +} + +// Handler serves Microsoft.DocumentDB/databaseAccounts ARM requests against a +// database driver. +type Handler struct { + db dbdriver.Database + attrs attrBackend // nil when the driver doesn't expose account attributes +} + +// New returns a Cosmos-account handler backed by db. +func New(db dbdriver.Database) *Handler { + h := &Handler{db: db} + if a, ok := db.(attrBackend); ok { + h.attrs = a + } + + return h +} + +// Matches claims only the ARM management path for database accounts. It never +// claims the cosmos data-plane path (/dbs/... URLs), so data-plane routing is +// undisturbed. It is disjoint from managedcassandra (cassandraClusters) and +// cosmospostgresql (Microsoft.DBforPostgreSQL) too. +func (*Handler) Matches(r *http.Request) bool { + rp, ok := azurearm.ParsePath(r.URL.Path) + if !ok { + return false + } + + return strings.EqualFold(rp.Provider, providerName) && + strings.EqualFold(rp.ResourceType, resourceType) +} + +// ServeHTTP routes the request based on path shape and method. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + rp, ok := azurearm.ParsePath(r.URL.Path) + if !ok { + azurearm.WriteError(w, http.StatusBadRequest, "InvalidPath", "malformed ARM path") + return + } + + if rp.ResourceName == "" { + azurearm.WriteError(w, http.StatusNotFound, "NotFound", "database account name required") + return + } + + switch r.Method { + case http.MethodPut: + h.createOrUpdate(w, r, &rp) + case http.MethodGet: + h.get(w, r, &rp) + case http.MethodDelete: + h.deleteAccount(w, r, &rp) + default: + azurearm.WriteError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") + } +} + +func (h *Handler) createOrUpdate(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var body armAccountCreate + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + name := rp.ResourceName + + // Upsert: an existing account (table) re-applies its cost attributes rather + // than erroring, matching real Azure's create-or-update semantics. + if err := h.db.CreateTable(r.Context(), dbdriver.TableConfig{Name: name}); err != nil && + !cerrors.IsAlreadyExists(err) { + azurearm.WriteCErr(w, err) + return + } + + attrs := dbdriver.AccountAttributes{Kind: body.Kind} + if body.Properties != nil { + attrs.OfferType = body.Properties.DatabaseAccountOfferType + attrs.EnableFreeTier = body.Properties.EnableFreeTier + attrs.Capabilities = capabilityNames(body.Properties.Capabilities) + } + + if h.attrs != nil { + h.attrs.SetTableAttributes(name, attrs) + } + + azurearm.WriteJSON(w, http.StatusOK, h.toARMAccount(r.Context(), rp, body.Location, body.Tags)) +} + +func (h *Handler) get(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if _, err := h.db.DescribeTable(r.Context(), rp.ResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, h.toARMAccount(r.Context(), rp, defaultLocation, nil)) +} + +func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if err := h.db.DeleteTable(r.Context(), rp.ResourceName); err != nil && !cerrors.IsNotFound(err) { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) +} + +// toARMAccount renders the ARM databaseAccounts wire shape, reading the stored +// cost attributes (kind / offer type / free tier / capabilities) back through +// the driver. +func (h *Handler) toARMAccount( + ctx context.Context, rp *azurearm.ResourcePath, location string, tags map[string]string, +) armAccount { + attrs := dbdriver.AccountAttributes{Kind: "GlobalDocumentDB", OfferType: "Standard"} + if h.attrs != nil { + if a, err := h.attrs.TableAttributes(ctx, rp.ResourceName); err == nil { + attrs = a + } + } + + if location == "" { + location = defaultLocation + } + + return armAccount{ + ID: azurearm.BuildResourceID(rp.Subscription, rp.ResourceGroup, providerName, resourceType, rp.ResourceName), + Name: rp.ResourceName, + Type: providerName + "/" + resourceType, + Location: location, + Kind: attrs.Kind, + Tags: tags, + Properties: &armAccountProps{ + DatabaseAccountOfferType: attrs.OfferType, + EnableFreeTier: attrs.EnableFreeTier, + Capabilities: toCapabilities(attrs.Capabilities), + ProvisioningState: "Succeeded", + }, + } +} + +func capabilityNames(caps []armCapability) []string { + if len(caps) == 0 { + return nil + } + + names := make([]string, 0, len(caps)) + + for _, c := range caps { + if c.Name != "" { + names = append(names, c.Name) + } + } + + return names +} + +func toCapabilities(names []string) []armCapability { + if len(names) == 0 { + return nil + } + + caps := make([]armCapability, 0, len(names)) + + for _, n := range names { + caps = append(caps, armCapability{Name: n}) + } + + return caps +} diff --git a/server/azure/cosmosaccount/sdk_test.go b/server/azure/cosmosaccount/sdk_test.go new file mode 100644 index 00000000..54e2e366 --- /dev/null +++ b/server/azure/cosmosaccount/sdk_test.go @@ -0,0 +1,126 @@ +// Real-SDK round-trip test: the live azure-sdk-for-go armcosmos +// DatabaseAccountsClient drives the in-memory handler end-to-end, proving the +// database-account cost fields (kind, databaseAccountOfferType, enableFreeTier, +// capabilities) survive a create -> get. + +package cosmosaccount_test + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2" + azureserver "github.com/stackshy/cloudemu/v2/server/azure" +) + +type fakeCred struct{} + +func (fakeCred) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "fake", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +func newDatabaseAccountsClient(t *testing.T) *armcosmos.DatabaseAccountsClient { + t.Helper() + + cloudP := cloudemu.NewAzure() + srv := azureserver.New(azureserver.Drivers{CosmosDB: cloudP.CosmosDB}) + + ts := httptest.NewTLSServer(srv) + t.Cleanup(ts.Close) + + myCloud := cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/", + Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ + cloud.ResourceManager: { + Endpoint: ts.URL, + Audience: "https://management.azure.com", + }, + }, + } + + opts := &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Cloud: myCloud, + Transport: ts.Client(), + Retry: policy.RetryOptions{MaxRetries: -1}, + }, + } + + client, err := armcosmos.NewDatabaseAccountsClient("sub-1", fakeCred{}, opts) + require.NoError(t, err) + + return client +} + +func TestSDKDatabaseAccountCreateGet(t *testing.T) { + ctx := context.Background() + client := newDatabaseAccountsClient(t) + + poller, err := client.BeginCreateOrUpdate(ctx, "rg-1", "cosmos1", armcosmos.DatabaseAccountCreateUpdateParameters{ + Location: to.Ptr("westus2"), + Kind: to.Ptr(armcosmos.DatabaseAccountKindMongoDB), + Properties: &armcosmos.DatabaseAccountCreateUpdateProperties{ + DatabaseAccountOfferType: to.Ptr("Standard"), + EnableFreeTier: to.Ptr(true), + Capabilities: []*armcosmos.Capability{ + {Name: to.Ptr("EnableServerless")}, + }, + Locations: []*armcosmos.Location{ + {LocationName: to.Ptr("westus2"), FailoverPriority: to.Ptr[int32](0)}, + }, + }, + Tags: map[string]*string{"env": to.Ptr("prod")}, + }, nil) + require.NoError(t, err) + + created, err := poller.PollUntilDone(ctx, nil) + require.NoError(t, err) + + assertCosmosCostFields(t, created.Kind, created.Properties) + require.NotNil(t, created.Name) + assert.Equal(t, "cosmos1", *created.Name) + + // ... and survive an independent GET. + got, err := client.Get(ctx, "rg-1", "cosmos1", nil) + require.NoError(t, err) + + assertCosmosCostFields(t, got.Kind, got.Properties) + require.NotNil(t, got.ID) + assert.Contains(t, *got.ID, "/providers/Microsoft.DocumentDB/databaseAccounts/cosmos1") +} + +func assertCosmosCostFields(t *testing.T, kind *armcosmos.DatabaseAccountKind, props *armcosmos.DatabaseAccountGetProperties) { + t.Helper() + + require.NotNil(t, kind) + assert.Equal(t, armcosmos.DatabaseAccountKindMongoDB, *kind) + + require.NotNil(t, props) + require.NotNil(t, props.DatabaseAccountOfferType) + assert.Equal(t, "Standard", *props.DatabaseAccountOfferType) + require.NotNil(t, props.EnableFreeTier) + assert.True(t, *props.EnableFreeTier) + + require.Len(t, props.Capabilities, 1) + require.NotNil(t, props.Capabilities[0].Name) + assert.Equal(t, "EnableServerless", *props.Capabilities[0].Name) +} + +func TestSDKDatabaseAccountGetMissing(t *testing.T) { + ctx := context.Background() + client := newDatabaseAccountsClient(t) + + _, err := client.Get(ctx, "rg-1", "nope", nil) + require.Error(t, err) +} diff --git a/server/azure/cosmosaccount/types.go b/server/azure/cosmosaccount/types.go new file mode 100644 index 00000000..6ea52bb7 --- /dev/null +++ b/server/azure/cosmosaccount/types.go @@ -0,0 +1,40 @@ +package cosmosaccount + +// armAccountCreate is the subset of the ARM databaseAccounts create body the +// emulator reads. armcosmos's DatabaseAccountCreateUpdateParameters marshals to +// these JSON field names. +type armAccountCreate struct { + Location string `json:"location,omitempty"` + Kind string `json:"kind,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + Properties *armAccountCreateProps `json:"properties,omitempty"` +} + +type armAccountCreateProps struct { + DatabaseAccountOfferType string `json:"databaseAccountOfferType,omitempty"` + EnableFreeTier bool `json:"enableFreeTier,omitempty"` + Capabilities []armCapability `json:"capabilities,omitempty"` +} + +// armCapability is the ARM capability shape ([{name}]). +type armCapability struct { + Name string `json:"name,omitempty"` +} + +// armAccount is the ARM databaseAccounts wire shape returned on create/get. +type armAccount struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Location string `json:"location,omitempty"` + Kind string `json:"kind,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + Properties *armAccountProps `json:"properties,omitempty"` +} + +type armAccountProps struct { + DatabaseAccountOfferType string `json:"databaseAccountOfferType,omitempty"` + EnableFreeTier bool `json:"enableFreeTier,omitempty"` + Capabilities []armCapability `json:"capabilities,omitempty"` + ProvisioningState string `json:"provisioningState,omitempty"` +} diff --git a/server/azure/cosmospostgresql/clusters.go b/server/azure/cosmospostgresql/clusters.go new file mode 100644 index 00000000..3093ebf7 --- /dev/null +++ b/server/azure/cosmospostgresql/clusters.go @@ -0,0 +1,253 @@ +package cosmospostgresql + +import ( + "context" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +const apiVersion = "2023-03-02-preview" + +func (*Handler) clusterID(rp *azurearm.ResourcePath, name string) string { + return azurearm.BuildResourceID(rp.Subscription, rp.ResourceGroup, providerName, resourceType, name) +} + +// childID builds the resource ID of a cluster sub-resource +// (.../serverGroupsv2/{cluster}/{sub}/{child}). +func (h *Handler) childID(rp *azurearm.ResourcePath, sub, child string) string { + return h.clusterID(rp, rp.ResourceName) + "/" + sub + "/" + child +} + +func (h *Handler) createOrUpdateCluster(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var body clusterResource + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := cpgdriver.CreateClusterConfig{ + Name: rp.ResourceName, + ResourceGroup: rp.ResourceGroup, + Location: body.Location, + Tags: body.Tags, + } + + if p := body.Properties; p != nil { + cfg.AdministratorLoginPassword = p.AdministratorLoginPassword + cfg.CitusVersion = p.CitusVersion + cfg.PostgresqlVersion = p.PostgresqlVersion + cfg.CoordinatorServerEdition = p.CoordinatorServerEdition + cfg.CoordinatorVCores = derefInt(p.CoordinatorVCores) + cfg.CoordinatorStorageQuotaInMb = derefInt(p.CoordinatorStorageQuotaInMb) + cfg.CoordinatorEnablePublicIPAccess = derefBool(p.CoordinatorEnablePublicIPAccess) + cfg.EnableShardsOnCoordinator = derefBool(p.EnableShardsOnCoordinator) + cfg.NodeServerEdition = p.NodeServerEdition + cfg.NodeCount = derefInt(p.NodeCount) + cfg.NodeVCores = derefInt(p.NodeVCores) + cfg.NodeStorageQuotaInMb = derefInt(p.NodeStorageQuotaInMb) + cfg.NodeEnablePublicIPAccess = derefBool(p.NodeEnablePublicIPAccess) + cfg.EnableHa = derefBool(p.EnableHa) + cfg.PreferredPrimaryZone = p.PreferredPrimaryZone + cfg.SourceResourceID = p.SourceResourceID + cfg.SourceLocation = p.SourceLocation + cfg.MaintenanceWindow = fromWireMW(p.MaintenanceWindow) + } + + c, created, err := h.db.CreateOrUpdateCluster(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + status := http.StatusOK + if created { + status = http.StatusCreated + } + + azurearm.WriteJSON(w, status, toARMCluster(c, h.clusterID(rp, c.Name))) +} + +func (h *Handler) getCluster(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + c, err := h.db.GetCluster(r.Context(), rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMCluster(c, h.clusterID(rp, c.Name))) +} + +func (h *Handler) updateCluster(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var body clusterResource + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + patch := cpgdriver.ClusterPatch{Tags: body.Tags} + + if p := body.Properties; p != nil { + patch.AdministratorLoginPassword = strPtrIfSet(p.AdministratorLoginPassword) + patch.CitusVersion = strPtrIfSet(p.CitusVersion) + patch.PostgresqlVersion = strPtrIfSet(p.PostgresqlVersion) + patch.CoordinatorServerEdition = strPtrIfSet(p.CoordinatorServerEdition) + patch.CoordinatorVCores = p.CoordinatorVCores + patch.CoordinatorStorageQuotaInMb = p.CoordinatorStorageQuotaInMb + patch.CoordinatorEnablePublicIPAccess = p.CoordinatorEnablePublicIPAccess + patch.EnableShardsOnCoordinator = p.EnableShardsOnCoordinator + patch.NodeServerEdition = strPtrIfSet(p.NodeServerEdition) + patch.NodeCount = p.NodeCount + patch.NodeVCores = p.NodeVCores + patch.NodeStorageQuotaInMb = p.NodeStorageQuotaInMb + patch.NodeEnablePublicIPAccess = p.NodeEnablePublicIPAccess + patch.PreferredPrimaryZone = strPtrIfSet(p.PreferredPrimaryZone) + patch.EnableHa = p.EnableHa + patch.MaintenanceWindow = fromWireMW(p.MaintenanceWindow) + } + + c, err := h.db.UpdateCluster(r.Context(), rp.ResourceGroup, rp.ResourceName, patch) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMCluster(c, h.clusterID(rp, c.Name))) +} + +func (h *Handler) deleteCluster(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if err := h.db.DeleteCluster(r.Context(), rp.ResourceGroup, rp.ResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) listClusters(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + var ( + clusters []cpgdriver.Cluster + err error + ) + + if rp.ResourceGroup == "" { + clusters, err = h.db.ListClustersBySubscription(r.Context()) + } else { + clusters, err = h.db.ListClustersByResourceGroup(r.Context(), rp.ResourceGroup) + } + + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := armList[clusterResource]{Value: make([]clusterResource, 0, len(clusters))} + + for i := range clusters { + id := azurearm.BuildResourceID(rp.Subscription, clusters[i].ResourceGroup, providerName, resourceType, clusters[i].Name) + out.Value = append(out.Value, toARMCluster(&clusters[i], id)) + } + + azurearm.WriteJSON(w, http.StatusOK, out) +} + +// postClusterAction handles restart/start/stop/promote. These are long-running +// actions with no result body; reply 202 + Location so the SDK's Location +// poller reads a terminal status from the operationStatuses URL. +func (*Handler) postClusterAction( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, + action func(context.Context, string, string) error, +) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w) + return + } + + if err := action(r.Context(), rp.ResourceGroup, rp.ResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + opID := rp.ResourceName + "-" + rp.SubResource + w.Header().Set("Location", asyncStatusURL(r, rp.Subscription, opID)) + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusAccepted) +} + +// asyncStatusURL builds an operationStatuses URL for a synthetic operation id. +func asyncStatusURL(r *http.Request, sub, opID string) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + + return scheme + "://" + r.Host + + "/subscriptions/" + sub + + "/providers/" + providerName + "/" + resourceLocations + "/global/" + subOperationStatuses + "/" + opID + + "?api-version=" + apiVersion +} + +// operationStatus reports a completed LRO for the poll the SDK issues. +func (*Handler) operationStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + azurearm.WriteJSON(w, http.StatusOK, map[string]string{"status": "Succeeded"}) +} + +func (h *Handler) checkNameAvailability(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w) + return + } + + var body nameAvailabilityRequest + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + na, err := h.db.CheckNameAvailability(r.Context(), body.Name, body.Type) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, nameAvailabilityResult{ + Name: na.Name, Type: na.Type, NameAvailable: boolPtr(na.NameAvailable), Message: na.Message, + }) +} + +func derefBool(p *bool) bool { return p != nil && *p } + +func strPtrIfSet(s string) *string { + if s == "" { + return nil + } + + return &s +} + +func derefInt(p *int) int { + if p == nil { + return 0 + } + + return *p +} + +func fromWireMW(mw *maintenanceWindow) *cpgdriver.MaintenanceWindow { + if mw == nil { + return nil + } + + return &cpgdriver.MaintenanceWindow{ + CustomWindow: mw.CustomWindow, DayOfWeek: derefInt(mw.DayOfWeek), + StartHour: derefInt(mw.StartHour), StartMinute: derefInt(mw.StartMinute), + } +} diff --git a/server/azure/cosmospostgresql/configurations.go b/server/azure/cosmospostgresql/configurations.go new file mode 100644 index 00000000..e18bb8bf --- /dev/null +++ b/server/azure/cosmospostgresql/configurations.go @@ -0,0 +1,108 @@ +package cosmospostgresql + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +// serveConfigurations handles the cluster-wide configurations collection and +// single-resource GET. +func (h *Handler) serveConfigurations(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + serveReadOnly(w, r, rp, h.listConfigurations, h.getConfiguration) +} + +func (h *Handler) getConfiguration(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + c, err := h.db.GetConfiguration(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMConfiguration(c, h.childID(rp, subConfigurations, c.Name))) +} + +func (h *Handler) listConfigurations(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + cfgs, err := h.db.ListConfigurations(r.Context(), rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, armListOf(cfgs, func(c *cpgdriver.Configuration) configurationResource { + return toARMConfiguration(c, h.childID(rp, subConfigurations, c.Name)) + })) +} + +// serveServerConfig handles the coordinator/node configuration GET + PUT +// (update). coordinator selects the coordinator role group; otherwise node. +func (h *Handler) serveServerConfig(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, coordinator bool) { + if rp.SubResourceName == "" { + writeMethodNotAllowed(w) + return + } + + switch r.Method { + case http.MethodGet: + h.getServerConfig(w, r, rp, coordinator) + case http.MethodPut: + h.updateServerConfig(w, r, rp, coordinator) + default: + writeMethodNotAllowed(w) + } +} + +func (h *Handler) getServerConfig(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, coordinator bool) { + var ( + sc *cpgdriver.ServerConfiguration + err error + sub = subNodeCfgs + ) + + if coordinator { + sub = subCoordinatorCfgs + sc, err = h.db.GetCoordinatorConfiguration(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName) + } else { + sc, err = h.db.GetNodeConfiguration(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName) + } + + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMServerConfiguration(sc, h.childID(rp, sub, sc.Name), sub)) +} + +func (h *Handler) updateServerConfig(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, coordinator bool) { + var body serverConfigurationResource + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + value := "" + if body.Properties != nil { + value = body.Properties.Value + } + + var ( + sc *cpgdriver.ServerConfiguration + err error + sub = subNodeCfgs + ) + + if coordinator { + sub = subCoordinatorCfgs + sc, err = h.db.UpdateCoordinatorConfiguration(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName, value) + } else { + sc, err = h.db.UpdateNodeConfiguration(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName, value) + } + + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMServerConfiguration(sc, h.childID(rp, sub, sc.Name), sub)) +} diff --git a/server/azure/cosmospostgresql/firewallrules.go b/server/azure/cosmospostgresql/firewallrules.go new file mode 100644 index 00000000..e4206266 --- /dev/null +++ b/server/azure/cosmospostgresql/firewallrules.go @@ -0,0 +1,74 @@ +package cosmospostgresql + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +func (h *Handler) serveFirewallRules(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + serveCRUD(w, r, rp, crudHandlers{ + put: h.createOrUpdateFirewallRule, + get: h.getFirewallRule, + del: h.deleteFirewallRule, + list: h.listFirewallRules, + }) +} + +func (h *Handler) createOrUpdateFirewallRule(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var body firewallRuleResource + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := cpgdriver.CreateFirewallRuleConfig{ + ResourceGroup: rp.ResourceGroup, + ClusterName: rp.ResourceName, + Name: rp.SubResourceName, + } + + if p := body.Properties; p != nil { + cfg.StartIPAddress = p.StartIPAddress + cfg.EndIPAddress = p.EndIPAddress + } + + fr, err := h.db.CreateOrUpdateFirewallRule(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMFirewallRule(fr, h.childID(rp, subFirewallRules, fr.Name))) +} + +func (h *Handler) getFirewallRule(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + fr, err := h.db.GetFirewallRule(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMFirewallRule(fr, h.childID(rp, subFirewallRules, fr.Name))) +} + +func (h *Handler) deleteFirewallRule(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if err := h.db.DeleteFirewallRule(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) listFirewallRules(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + rules, err := h.db.ListFirewallRules(r.Context(), rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, armListOf(rules, func(fr *cpgdriver.FirewallRule) firewallRuleResource { + return toARMFirewallRule(fr, h.childID(rp, subFirewallRules, fr.Name)) + })) +} diff --git a/server/azure/cosmospostgresql/handler.go b/server/azure/cosmospostgresql/handler.go new file mode 100644 index 00000000..5ac04567 --- /dev/null +++ b/server/azure/cosmospostgresql/handler.go @@ -0,0 +1,215 @@ +// Package cosmospostgresql implements the Azure Cosmos DB for PostgreSQL ARM +// REST API (Microsoft.DBforPostgreSQL/serverGroupsv2) as a server.Handler. Real +// github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmosforpostgresql/armcosmosforpostgresql +// clients configured with a custom endpoint hit this handler the same way they +// hit management.azure.com. +// +// Create/update RPCs return the resource inline with a terminal +// provisioningState so the SDK's LRO poller completes on the first response; +// the cluster start/stop/restart/promote actions reply 202 + Location and the +// poller reads a terminal status from the operationStatuses URL. +package cosmospostgresql + +import ( + "context" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +// Handler serves Microsoft.DBforPostgreSQL/serverGroupsv2 ARM requests. +type Handler struct { + db cpgdriver.CosmosPostgreSQL +} + +// New returns a Cosmos DB for PostgreSQL handler backed by db. +func New(db cpgdriver.CosmosPostgreSQL) *Handler { + return &Handler{db: db} +} + +// Matches claims ARM Microsoft.DBforPostgreSQL/serverGroupsv2 paths, the +// subscription-scoped checkNameAvailability path, and the +// locations/operationStatuses paths its long-running actions poll. +func (*Handler) Matches(r *http.Request) bool { + rp, ok := azurearm.ParsePath(r.URL.Path) + if !ok { + return false + } + + if rp.Provider != providerName { + return false + } + + switch rp.ResourceType { + case resourceType, resourceCheckName: + return true + case resourceLocations: + return rp.SubResource == subOperationStatuses + default: + return false + } +} + +// ServeHTTP routes the request based on path shape and method. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + rp, ok := azurearm.ParsePath(r.URL.Path) + if !ok { + azurearm.WriteError(w, http.StatusBadRequest, "InvalidPath", "malformed ARM path") + return + } + + switch rp.ResourceType { + case resourceLocations: + h.operationStatus(w, r) + case resourceCheckName: + h.checkNameAvailability(w, r) + case resourceType: + h.serveServerGroups(w, r, &rp) + default: + azurearm.WriteError(w, http.StatusNotFound, "NotFound", "unsupported path: "+r.URL.Path) + } +} + +func (h *Handler) serveServerGroups(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + // Collection: .../serverGroupsv2 (resource-group- or subscription-scoped). + if rp.ResourceName == "" { + h.listClusters(w, r, rp) + return + } + + // Child paths: .../serverGroupsv2/{name}/{subResource}[/{subName}]. + if rp.SubResource != "" { + h.serveClusterChild(w, r, rp) + return + } + + h.serveCluster(w, r, rp) +} + +func (h *Handler) serveCluster(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + switch r.Method { + case http.MethodPut: + h.createOrUpdateCluster(w, r, rp) + case http.MethodGet: + h.getCluster(w, r, rp) + case http.MethodPatch: + h.updateCluster(w, r, rp) + case http.MethodDelete: + h.deleteCluster(w, r, rp) + default: + writeMethodNotAllowed(w) + } +} + +func (h *Handler) serveClusterChild(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if action := h.clusterAction(rp.SubResource); action != nil { + h.postClusterAction(w, r, rp, action) + return + } + + switch rp.SubResource { + case subFirewallRules: + h.serveFirewallRules(w, r, rp) + case subRoles: + h.serveRoles(w, r, rp) + case subServers: + h.serveServers(w, r, rp) + case subConfigurations: + h.serveConfigurations(w, r, rp) + case subCoordinatorCfgs: + h.serveServerConfig(w, r, rp, true) + case subNodeCfgs: + h.serveServerConfig(w, r, rp, false) + case subPrivateEPs: + h.servePrivateEndpoints(w, r, rp) + case subPrivateLinks: + h.servePrivateLinks(w, r, rp) + default: + azurearm.WriteError(w, http.StatusNotFound, "NotFound", "unsupported sub-resource: "+rp.SubResource) + } +} + +// clusterAction returns the driver action for a POST verb sub-resource, or nil +// if the sub-resource isn't a cluster action. +func (h *Handler) clusterAction(sub string) func(context.Context, string, string) error { + switch sub { + case actionRestart: + return h.db.RestartCluster + case actionStart: + return h.db.StartCluster + case actionStop: + return h.db.StopCluster + case actionPromote: + return h.db.PromoteReadReplica + default: + return nil + } +} + +// crudHandlers is the set of method handlers for a cluster child collection. +type crudHandlers struct { + put func(http.ResponseWriter, *http.Request, *azurearm.ResourcePath) + get func(http.ResponseWriter, *http.Request, *azurearm.ResourcePath) + del func(http.ResponseWriter, *http.Request, *azurearm.ResourcePath) + list func(http.ResponseWriter, *http.Request, *azurearm.ResourcePath) +} + +// serveCRUD routes a child collection: GET on the collection lists, and +// PUT/GET/DELETE on a named item dispatch to the item handlers. +func serveCRUD(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, hs crudHandlers) { + if rp.SubResourceName == "" { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + hs.list(w, r, rp) + + return + } + + switch r.Method { + case http.MethodPut: + hs.put(w, r, rp) + case http.MethodGet: + hs.get(w, r, rp) + case http.MethodDelete: + hs.del(w, r, rp) + default: + writeMethodNotAllowed(w) + } +} + +// serveReadOnly routes a read-only child collection: GET on the collection +// lists, GET on a named item fetches it; any other method is rejected. +func serveReadOnly( + w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, + list, get func(http.ResponseWriter, *http.Request, *azurearm.ResourcePath), +) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + if rp.SubResourceName == "" { + list(w, r, rp) + return + } + + get(w, r, rp) +} + +// armListOf builds an ARM list envelope by converting each driver value. +func armListOf[D any, W any](items []D, conv func(*D) W) armList[W] { + out := armList[W]{Value: make([]W, 0, len(items))} + for i := range items { + out.Value = append(out.Value, conv(&items[i])) + } + + return out +} + +func writeMethodNotAllowed(w http.ResponseWriter) { + azurearm.WriteError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") +} diff --git a/server/azure/cosmospostgresql/privateendpoints.go b/server/azure/cosmospostgresql/privateendpoints.go new file mode 100644 index 00000000..2541dcfe --- /dev/null +++ b/server/azure/cosmospostgresql/privateendpoints.go @@ -0,0 +1,97 @@ +package cosmospostgresql + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +func (h *Handler) servePrivateEndpoints(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + serveCRUD(w, r, rp, crudHandlers{ + put: h.createOrUpdatePrivateEndpoint, + get: h.getPrivateEndpoint, + del: h.deletePrivateEndpoint, + list: h.listPrivateEndpoints, + }) +} + +func (h *Handler) createOrUpdatePrivateEndpoint(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var body privateEndpointConnectionResource + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + status, description := "", "" + if p := body.Properties; p != nil && p.PrivateLinkServiceConnectionState != nil { + status = p.PrivateLinkServiceConnectionState.Status + description = p.PrivateLinkServiceConnectionState.Description + } + + pec, err := h.db.CreateOrUpdatePrivateEndpointConnection( + r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName, status, description, + ) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMPrivateEndpointConnection(pec, h.childID(rp, subPrivateEPs, pec.Name))) +} + +func (h *Handler) getPrivateEndpoint(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + pec, err := h.db.GetPrivateEndpointConnection(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMPrivateEndpointConnection(pec, h.childID(rp, subPrivateEPs, pec.Name))) +} + +func (h *Handler) deletePrivateEndpoint(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if err := h.db.DeletePrivateEndpointConnection(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) listPrivateEndpoints(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + pecs, err := h.db.ListPrivateEndpointConnections(r.Context(), rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, armListOf(pecs, func(pec *cpgdriver.PrivateEndpointConnection) privateEndpointConnectionResource { + return toARMPrivateEndpointConnection(pec, h.childID(rp, subPrivateEPs, pec.Name)) + })) +} + +func (h *Handler) servePrivateLinks(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + serveReadOnly(w, r, rp, h.listPrivateLinks, h.getPrivateLink) +} + +func (h *Handler) getPrivateLink(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + plr, err := h.db.GetPrivateLinkResource(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMPrivateLinkResource(plr, h.childID(rp, subPrivateLinks, plr.Name))) +} + +func (h *Handler) listPrivateLinks(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + plrs, err := h.db.ListPrivateLinkResources(r.Context(), rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, armListOf(plrs, func(plr *cpgdriver.PrivateLinkResource) privateLinkResource { + return toARMPrivateLinkResource(plr, h.childID(rp, subPrivateLinks, plr.Name)) + })) +} diff --git a/server/azure/cosmospostgresql/roles.go b/server/azure/cosmospostgresql/roles.go new file mode 100644 index 00000000..c350e0bf --- /dev/null +++ b/server/azure/cosmospostgresql/roles.go @@ -0,0 +1,72 @@ +package cosmospostgresql + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +func (h *Handler) serveRoles(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + serveCRUD(w, r, rp, crudHandlers{ + put: h.createRole, + get: h.getRole, + del: h.deleteRole, + list: h.listRoles, + }) +} + +func (h *Handler) createRole(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var body roleResource + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := cpgdriver.CreateRoleConfig{ + ResourceGroup: rp.ResourceGroup, + ClusterName: rp.ResourceName, + Name: rp.SubResourceName, + } + if p := body.Properties; p != nil { + cfg.Password = p.Password + } + + role, err := h.db.CreateRole(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMRole(role, h.childID(rp, subRoles, role.Name))) +} + +func (h *Handler) getRole(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + role, err := h.db.GetRole(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMRole(role, h.childID(rp, subRoles, role.Name))) +} + +func (h *Handler) deleteRole(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if err := h.db.DeleteRole(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName); err != nil { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) listRoles(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + roles, err := h.db.ListRoles(r.Context(), rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, armListOf(roles, func(role *cpgdriver.Role) roleResource { + return toARMRole(role, h.childID(rp, subRoles, role.Name)) + })) +} diff --git a/server/azure/cosmospostgresql/sdk_roundtrip_test.go b/server/azure/cosmospostgresql/sdk_roundtrip_test.go new file mode 100644 index 00000000..427f84b5 --- /dev/null +++ b/server/azure/cosmospostgresql/sdk_roundtrip_test.go @@ -0,0 +1,711 @@ +package cosmospostgresql_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmosforpostgresql/armcosmosforpostgresql" + + "github.com/stackshy/cloudemu/v2" + azureserver "github.com/stackshy/cloudemu/v2/server/azure" +) + +const subID = "sub-123" + +func deref[T any](p *T) (v T) { + if p != nil { + return *p + } + + return v +} + +type fakeCred struct{} + +func (fakeCred) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "fake", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +func newFactory(t *testing.T) *armcosmosforpostgresql.ClientFactory { + t.Helper() + + cloudP := cloudemu.NewAzure() + ts := httptest.NewTLSServer(azureserver.NewFromProvider(cloudP)) + t.Cleanup(ts.Close) + + myCloud := cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/", + Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ + cloud.ResourceManager: {Endpoint: ts.URL, Audience: "https://management.azure.com"}, + }, + } + + opts := &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{Cloud: myCloud, Transport: ts.Client()}, + } + + f, err := armcosmosforpostgresql.NewClientFactory(subID, fakeCred{}, opts) + if err != nil { + t.Fatalf("client factory: %v", err) + } + + return f +} + +func mustCreateCluster(t *testing.T, cc *armcosmosforpostgresql.ClustersClient, ctx context.Context, nodeCount int32) { + t.Helper() + + poller, err := cc.BeginCreate(ctx, "rg1", "pg1", armcosmosforpostgresql.Cluster{ + Location: to.Ptr("eastus"), + Properties: &armcosmosforpostgresql.ClusterProperties{ + AdministratorLoginPassword: to.Ptr("Sup3rSecret!"), + CoordinatorVCores: to.Ptr[int32](4), + NodeCount: to.Ptr(nodeCount), + CitusVersion: to.Ptr("12.1"), + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("create poll: %v", err) + } +} + +func TestSDKClusterLifecycle(t *testing.T) { + f := newFactory(t) + cc := f.NewClustersClient() + ctx := context.Background() + + poller, err := cc.BeginCreate(ctx, "rg1", "pg1", armcosmosforpostgresql.Cluster{ + Location: to.Ptr("eastus"), + Tags: map[string]*string{"env": to.Ptr("prod")}, + Properties: &armcosmosforpostgresql.ClusterProperties{ + AdministratorLoginPassword: to.Ptr("Sup3rSecret!"), + NodeCount: to.Ptr[int32](2), + CitusVersion: to.Ptr("12.1"), + EnableHa: to.Ptr(true), + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreate: %v", err) + } + + created, err := poller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("create poll: %v", err) + } + + if deref(created.Name) != "pg1" || deref(created.Properties.ProvisioningState) != "Succeeded" { + t.Fatalf("created wrong: name=%q state=%q", deref(created.Name), deref(created.Properties.ProvisioningState)) + } + + if deref(created.Properties.NodeCount) != 2 || !deref(created.Properties.EnableHa) { + t.Fatalf("created props wrong: %+v", created.Properties) + } + + got, err := cc.Get(ctx, "rg1", "pg1", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if deref(got.Properties.CitusVersion) != "12.1" { + t.Fatalf("get props wrong: %+v", got.Properties) + } + + pager := cc.NewListByResourceGroupPager("rg1", nil) + + page, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("list by rg: %v", err) + } + + if len(page.Value) != 1 { + t.Fatalf("list by rg: got %d, want 1", len(page.Value)) + } + + del, err := cc.BeginDelete(ctx, "rg1", "pg1", nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err := del.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("delete poll: %v", err) + } + + if _, err := cc.Get(ctx, "rg1", "pg1", nil); err == nil { + t.Fatal("get after delete: expected error") + } +} + +func TestSDKUpdateActionsAndList(t *testing.T) { + f := newFactory(t) + cc := f.NewClustersClient() + ctx := context.Background() + + mustCreateCluster(t, cc, ctx, 2) + + // PATCH: scale nodes + tags. + up, err := cc.BeginUpdate(ctx, "rg1", "pg1", armcosmosforpostgresql.ClusterForUpdate{ + Tags: map[string]*string{"tier": to.Ptr("gold")}, + Properties: &armcosmosforpostgresql.ClusterPropertiesForUpdate{NodeCount: to.Ptr[int32](4)}, + }, nil) + if err != nil { + t.Fatalf("BeginUpdate: %v", err) + } + + updated, err := up.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("update poll: %v", err) + } + + if deref(updated.Properties.NodeCount) != 4 || deref(updated.Tags["tier"]) != "gold" { + t.Fatalf("patch not applied: %+v tags=%v", updated.Properties, updated.Tags) + } + + // List by subscription. + subPager := cc.NewListPager(nil) + + subPage, err := subPager.NextPage(ctx) + if err != nil { + t.Fatalf("list by sub: %v", err) + } + + if len(subPage.Value) != 1 { + t.Fatalf("list by sub: got %d, want 1", len(subPage.Value)) + } + + // Stop / start / restart round-trip (Location LRO). + for _, action := range []func() error{ + func() error { p, e := cc.BeginStop(ctx, "rg1", "pg1", nil); return pollErr(ctx, p, e) }, + func() error { p, e := cc.BeginStart(ctx, "rg1", "pg1", nil); return pollErr(ctx, p, e) }, + func() error { p, e := cc.BeginRestart(ctx, "rg1", "pg1", nil); return pollErr(ctx, p, e) }, + } { + if err := action(); err != nil { + t.Fatalf("cluster action: %v", err) + } + } +} + +func pollErr[T any](ctx context.Context, p *runtime.Poller[T], err error) error { + if err != nil { + return err + } + + _, err = p.PollUntilDone(ctx, nil) + + return err +} + +func TestSDKFirewallRulesRolesServers(t *testing.T) { + f := newFactory(t) + cc := f.NewClustersClient() + ctx := context.Background() + + mustCreateCluster(t, cc, ctx, 2) + + // Firewall rule. + fwc := f.NewFirewallRulesClient() + + fwPoller, err := fwc.BeginCreateOrUpdate(ctx, "rg1", "pg1", "allow-all", armcosmosforpostgresql.FirewallRule{ + Properties: &armcosmosforpostgresql.FirewallRuleProperties{ + StartIPAddress: to.Ptr("0.0.0.0"), EndIPAddress: to.Ptr("255.255.255.255"), + }, + }, nil) + if err != nil { + t.Fatalf("fw BeginCreateOrUpdate: %v", err) + } + + fw, err := fwPoller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("fw poll: %v", err) + } + + if deref(fw.Properties.EndIPAddress) != "255.255.255.255" { + t.Fatalf("fw props wrong: %+v", fw.Properties) + } + + fwPage, err := fwc.NewListByClusterPager("rg1", "pg1", nil).NextPage(ctx) + if err != nil || len(fwPage.Value) != 1 { + t.Fatalf("fw list: %v len=%d", err, len(fwPage.Value)) + } + + // Role. + rc := f.NewRolesClient() + + rolePoller, err := rc.BeginCreate(ctx, "rg1", "pg1", "app", armcosmosforpostgresql.Role{ + Properties: &armcosmosforpostgresql.RoleProperties{Password: to.Ptr("R0lePass!")}, + }, nil) + if err != nil { + t.Fatalf("role BeginCreate: %v", err) + } + + if _, err := rolePoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("role poll: %v", err) + } + + if _, err := rc.Get(ctx, "rg1", "pg1", "app", nil); err != nil { + t.Fatalf("role Get: %v", err) + } + + // Servers (derived nodes): one coordinator + two workers. + sc := f.NewServersClient() + + srvPage, err := sc.NewListByClusterPager("rg1", "pg1", nil).NextPage(ctx) + if err != nil { + t.Fatalf("server list: %v", err) + } + + if len(srvPage.Value) != 3 { + t.Fatalf("server list: got %d, want 3", len(srvPage.Value)) + } + + if _, err := sc.Get(ctx, "rg1", "pg1", "pg1-c", nil); err != nil { + t.Fatalf("server Get coordinator: %v", err) + } +} + +func TestSDKConfigurationsAndReplicaAndErrors(t *testing.T) { + f := newFactory(t) + cc := f.NewClustersClient() + ctx := context.Background() + + mustCreateCluster(t, cc, ctx, 1) + + // Configurations: list, get coordinator, update coordinator. + cfgc := f.NewConfigurationsClient() + + cfgPage, err := cfgc.NewListByClusterPager("rg1", "pg1", nil).NextPage(ctx) + if err != nil || len(cfgPage.Value) == 0 { + t.Fatalf("config list: %v len=%d", err, len(cfgPage.Value)) + } + + coord, err := cfgc.GetCoordinator(ctx, "rg1", "pg1", "max_connections", nil) + if err != nil || deref(coord.Properties.Value) != "300" { + t.Fatalf("get coordinator config: %v %+v", err, coord.Properties) + } + + upPoller, err := cfgc.BeginUpdateOnCoordinator(ctx, "rg1", "pg1", "max_connections", armcosmosforpostgresql.ServerConfiguration{ + Properties: &armcosmosforpostgresql.ServerConfigurationProperties{Value: to.Ptr("500")}, + }, nil) + if err != nil { + t.Fatalf("BeginUpdateOnCoordinator: %v", err) + } + + updatedCfg, err := upPoller.PollUntilDone(ctx, nil) + if err != nil || deref(updatedCfg.Properties.Value) != "500" { + t.Fatalf("coordinator config update: %v %+v", err, updatedCfg.Properties) + } + + // checkNameAvailability: a taken name is explicitly unavailable (assert the + // pointer is present so a dropped field would regress loudly)... + na, err := cc.CheckNameAvailability(ctx, armcosmosforpostgresql.NameAvailabilityRequest{Name: to.Ptr("pg1")}, nil) + if err != nil { + t.Fatalf("CheckNameAvailability taken: %v", err) + } + + if na.NameAvailable == nil || *na.NameAvailable { + t.Fatalf("existing name should be explicitly unavailable: %v", na.NameAvailable) + } + + // ...and a free name is explicitly available. + free, err := cc.CheckNameAvailability(ctx, armcosmosforpostgresql.NameAvailabilityRequest{Name: to.Ptr("brand-new")}, nil) + if err != nil { + t.Fatalf("CheckNameAvailability free: %v", err) + } + + if free.NameAvailable == nil || !*free.NameAvailable { + t.Fatalf("free name should be explicitly available: %v", free.NameAvailable) + } + + // Typed 404 for a missing cluster. + _, err = cc.Get(ctx, "rg1", "ghost", nil) + + var respErr *azcore.ResponseError + if !errors.As(err, &respErr) || respErr.StatusCode != 404 { + t.Fatalf("get missing cluster: got %v, want 404 ResponseError", err) + } +} + +func TestSDKChildGetDeleteAndPrivateLinks(t *testing.T) { + f := newFactory(t) + cc := f.NewClustersClient() + ctx := context.Background() + + mustCreateCluster(t, cc, ctx, 2) + + // Firewall rule get + delete. + fwc := f.NewFirewallRulesClient() + + fwPoller, err := fwc.BeginCreateOrUpdate(ctx, "rg1", "pg1", "fw", armcosmosforpostgresql.FirewallRule{ + Properties: &armcosmosforpostgresql.FirewallRuleProperties{ + StartIPAddress: to.Ptr("10.0.0.0"), EndIPAddress: to.Ptr("10.0.0.255"), + }, + }, nil) + if err == nil { + _, err = fwPoller.PollUntilDone(ctx, nil) + } + + if err != nil { + t.Fatalf("fw create: %v", err) + } + + if _, err := fwc.Get(ctx, "rg1", "pg1", "fw", nil); err != nil { + t.Fatalf("fw Get: %v", err) + } + + fwDel, err := fwc.BeginDelete(ctx, "rg1", "pg1", "fw", nil) + if err == nil { + _, err = fwDel.PollUntilDone(ctx, nil) + } + + if err != nil { + t.Fatalf("fw delete: %v", err) + } + + // Role delete + list. + rc := f.NewRolesClient() + + rolePoller, err := rc.BeginCreate(ctx, "rg1", "pg1", "app", armcosmosforpostgresql.Role{ + Properties: &armcosmosforpostgresql.RoleProperties{Password: to.Ptr("R0lePass!")}, + }, nil) + if err == nil { + _, err = rolePoller.PollUntilDone(ctx, nil) + } + + if err != nil { + t.Fatalf("role create: %v", err) + } + + rolePage, err := rc.NewListByClusterPager("rg1", "pg1", nil).NextPage(ctx) + if err != nil || len(rolePage.Value) != 1 || deref(rolePage.Value[0].Name) != "app" { + t.Fatalf("role list: err=%v page=%+v", err, rolePage.Value) + } + + roleDel, err := rc.BeginDelete(ctx, "rg1", "pg1", "app", nil) + if err == nil { + _, err = roleDel.PollUntilDone(ctx, nil) + } + + if err != nil { + t.Fatalf("role delete: %v", err) + } + + // Node configuration get + update + single get + list-by-server. + cfgc := f.NewConfigurationsClient() + + if _, err := cfgc.GetNode(ctx, "rg1", "pg1", "max_connections", nil); err != nil { + t.Fatalf("config GetNode: %v", err) + } + + nodePoller, err := cfgc.BeginUpdateOnNode(ctx, "rg1", "pg1", "max_connections", armcosmosforpostgresql.ServerConfiguration{ + Properties: &armcosmosforpostgresql.ServerConfigurationProperties{Value: to.Ptr("400")}, + }, nil) + if err == nil { + _, err = nodePoller.PollUntilDone(ctx, nil) + } + + if err != nil { + t.Fatalf("config UpdateOnNode: %v", err) + } + + if _, err := cfgc.Get(ctx, "rg1", "pg1", "max_connections", nil); err != nil { + t.Fatalf("config Get: %v", err) + } + + if _, err := cfgc.NewListByServerPager("rg1", "pg1", "pg1-c", nil).NextPage(ctx); err != nil { + t.Fatalf("config ListByServer: %v", err) + } + + // Private-endpoint connection full CRUD. + pec := f.NewPrivateEndpointConnectionsClient() + + pecPoller, err := pec.BeginCreateOrUpdate(ctx, "rg1", "pg1", "pe1", armcosmosforpostgresql.PrivateEndpointConnection{ + Properties: &armcosmosforpostgresql.PrivateEndpointConnectionProperties{ + PrivateLinkServiceConnectionState: &armcosmosforpostgresql.PrivateLinkServiceConnectionState{ + Status: to.Ptr(armcosmosforpostgresql.PrivateEndpointServiceConnectionStatusApproved), + }, + }, + }, nil) + if err == nil { + _, err = pecPoller.PollUntilDone(ctx, nil) + } + + if err != nil { + t.Fatalf("PE create: %v", err) + } + + if _, err := pec.Get(ctx, "rg1", "pg1", "pe1", nil); err != nil { + t.Fatalf("PE Get: %v", err) + } + + if _, err := pec.NewListByClusterPager("rg1", "pg1", nil).NextPage(ctx); err != nil { + t.Fatalf("PE list: %v", err) + } + + peDel, err := pec.BeginDelete(ctx, "rg1", "pg1", "pe1", nil) + if err == nil { + _, err = peDel.PollUntilDone(ctx, nil) + } + + if err != nil { + t.Fatalf("PE delete: %v", err) + } + + // Private-link resources list + get. + plr := f.NewPrivateLinkResourcesClient() + + if _, err := plr.NewListByClusterPager("rg1", "pg1", nil).NextPage(ctx); err != nil { + t.Fatalf("PLR list: %v", err) + } + + if _, err := plr.Get(ctx, "rg1", "pg1", "coordinator", nil); err != nil { + t.Fatalf("PLR get: %v", err) + } +} + +func TestSDKReplicaPromoteAndStateGuards(t *testing.T) { + f := newFactory(t) + cc := f.NewClustersClient() + ctx := context.Background() + + // Primary + replica. + primary, err := cc.BeginCreate(ctx, "rg1", "primary", armcosmosforpostgresql.Cluster{ + Location: to.Ptr("eastus"), + Properties: &armcosmosforpostgresql.ClusterProperties{AdministratorLoginPassword: to.Ptr("Sup3rSecret!"), NodeCount: to.Ptr[int32](2)}, + }, nil) + if err == nil { + _, err = primary.PollUntilDone(ctx, nil) + } + + if err != nil { + t.Fatalf("create primary: %v", err) + } + + srcID := "/subscriptions/" + subID + "/resourceGroups/rg1/providers/Microsoft.DBforPostgreSQL/serverGroupsv2/primary" + + rep, err := cc.BeginCreate(ctx, "rg1", "replica", armcosmosforpostgresql.Cluster{ + Location: to.Ptr("westus"), + Properties: &armcosmosforpostgresql.ClusterProperties{ + SourceResourceID: to.Ptr(srcID), SourceLocation: to.Ptr("eastus"), + }, + }, nil) + if err == nil { + _, err = rep.PollUntilDone(ctx, nil) + } + + if err != nil { + t.Fatalf("create replica: %v", err) + } + + // Promote the replica (Location LRO). + prom, err := cc.BeginPromoteReadReplica(ctx, "rg1", "replica", nil) + if err == nil { + _, err = prom.PollUntilDone(ctx, nil) + } + + if err != nil { + t.Fatalf("promote: %v", err) + } + + promoted, err := cc.Get(ctx, "rg1", "replica", nil) + if err != nil { + t.Fatalf("get promoted: %v", err) + } + + if promoted.Properties.SourceResourceID != nil && deref(promoted.Properties.SourceResourceID) != "" { + t.Fatalf("replica still linked after promote: %+v", promoted.Properties.SourceResourceID) + } + + // Restart-while-Stopped is rejected (409). + stop, err := cc.BeginStop(ctx, "rg1", "primary", nil) + if err == nil { + _, err = stop.PollUntilDone(ctx, nil) + } + + if err != nil { + t.Fatalf("stop: %v", err) + } + + restart, err := cc.BeginRestart(ctx, "rg1", "primary", nil) + if err == nil { + _, err = restart.PollUntilDone(ctx, nil) + } + + var respErr *azcore.ResponseError + if !errors.As(err, &respErr) || respErr.StatusCode != 409 { + t.Fatalf("restart while stopped: got %v, want 409", err) + } +} + +func TestSDKSingleNodeAndServerNames(t *testing.T) { + f := newFactory(t) + cc := f.NewClustersClient() + ctx := context.Background() + + // A single-node cluster (nodeCount=0) must round-trip: the response carries + // an explicit 0, not an omitted key that deserializes to nil. + single, err := cc.BeginCreate(ctx, "rg1", "solo", armcosmosforpostgresql.Cluster{ + Location: to.Ptr("eastus"), + Properties: &armcosmosforpostgresql.ClusterProperties{AdministratorLoginPassword: to.Ptr("Sup3rSecret!"), NodeCount: to.Ptr[int32](0)}, + }, nil) + if err == nil { + _, err = single.PollUntilDone(ctx, nil) + } + + if err != nil { + t.Fatalf("create single-node: %v", err) + } + + got, err := cc.Get(ctx, "rg1", "solo", nil) + if err != nil { + t.Fatalf("get single-node: %v", err) + } + + if got.Properties.NodeCount == nil || *got.Properties.NodeCount != 0 { + t.Fatalf("single-node NodeCount not represented: %v", got.Properties.NodeCount) + } + + // serverNames enumerates the coordinator only (no workers). + if len(got.Properties.ServerNames) != 1 { + t.Fatalf("single-node serverNames: got %d, want 1", len(got.Properties.ServerNames)) + } + + // A 2-worker cluster reports coordinator + 2 workers, and the coordinator + // FQDN matches the servers sub-resource. + multi, err := cc.BeginCreate(ctx, "rg1", "multi", armcosmosforpostgresql.Cluster{ + Location: to.Ptr("eastus"), + Properties: &armcosmosforpostgresql.ClusterProperties{AdministratorLoginPassword: to.Ptr("Sup3rSecret!"), NodeCount: to.Ptr[int32](2)}, + }, nil) + if err == nil { + _, err = multi.PollUntilDone(ctx, nil) + } + + if err != nil { + t.Fatalf("create multi: %v", err) + } + + m, err := cc.Get(ctx, "rg1", "multi", nil) + if err != nil { + t.Fatalf("get multi: %v", err) + } + + if len(m.Properties.ServerNames) != 3 { + t.Fatalf("multi serverNames: got %d, want 3", len(m.Properties.ServerNames)) + } + + srv, err := f.NewServersClient().Get(ctx, "rg1", "multi", "multi-c", nil) + if err != nil { + t.Fatalf("servers Get: %v", err) + } + + if deref(m.Properties.ServerNames[0].FullyQualifiedDomainName) != deref(srv.Properties.FullyQualifiedDomainName) { + t.Fatalf("coordinator FQDN mismatch: serverNames=%q servers=%q", + deref(m.Properties.ServerNames[0].FullyQualifiedDomainName), deref(srv.Properties.FullyQualifiedDomainName)) + } +} + +func TestMalformedBodyRejected(t *testing.T) { + cloudP := cloudemu.NewAzure() + ts := httptest.NewServer(azureserver.NewFromProvider(cloudP)) + t.Cleanup(ts.Close) + + url := ts.URL + "/subscriptions/" + subID + + "/resourceGroups/rg1/providers/Microsoft.DBforPostgreSQL/serverGroupsv2/pg1?api-version=2023-03-02-preview" + + req, err := http.NewRequest(http.MethodPut, url, strings.NewReader("{not json")) + if err != nil { + t.Fatalf("new request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("malformed body: got %d, want 400", resp.StatusCode) + } +} + +func TestServerErrorPaths(t *testing.T) { + cloudP := cloudemu.NewAzure() + ts := httptest.NewServer(azureserver.NewFromProvider(cloudP)) + t.Cleanup(ts.Close) + + base := ts.URL + "/subscriptions/" + subID + "/resourceGroups/rg1/providers/Microsoft.DBforPostgreSQL" + const ver = "?api-version=2023-03-02-preview" + + do := func(method, path, body string) int { + t.Helper() + + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + + req, err := http.NewRequest(method, path, rdr) + if err != nil { + t.Fatalf("new request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + + return resp.StatusCode + } + + // Create a cluster so child error paths are distinct from a missing parent. + // A create returns 201; a re-PUT of the same cluster returns 200. + if code := do(http.MethodPut, base+"/serverGroupsv2/pg1"+ver, `{"location":"eastus","properties":{"nodeCount":1}}`); code != http.StatusCreated { + t.Fatalf("create cluster: got %d, want 201", code) + } + + if code := do(http.MethodPut, base+"/serverGroupsv2/pg1"+ver, `{"location":"eastus","properties":{"nodeCount":1}}`); code != http.StatusOK { + t.Fatalf("re-PUT cluster: got %d, want 200", code) + } + + cases := []struct { + name, method, path string + want int + }{ + {"get missing firewall rule", http.MethodGet, base + "/serverGroupsv2/pg1/firewallRules/nope" + ver, http.StatusNotFound}, + {"method not allowed on firewall item", http.MethodPost, base + "/serverGroupsv2/pg1/firewallRules/fw" + ver, http.StatusMethodNotAllowed}, + {"delete missing role", http.MethodDelete, base + "/serverGroupsv2/pg1/roles/ghost" + ver, http.StatusNotFound}, + {"unsupported sub-resource", http.MethodGet, base + "/serverGroupsv2/pg1/bogus" + ver, http.StatusNotFound}, + {"get missing cluster", http.MethodGet, base + "/serverGroupsv2/ghost" + ver, http.StatusNotFound}, + {"checkNameAvailability wrong method", http.MethodGet, base + "/checkNameAvailability" + ver, http.StatusMethodNotAllowed}, + {"get missing private endpoint", http.MethodGet, base + "/serverGroupsv2/pg1/privateEndpointConnections/nope" + ver, http.StatusNotFound}, + {"get missing private link", http.MethodGet, base + "/serverGroupsv2/pg1/privateLinkResources/nope" + ver, http.StatusNotFound}, + {"get missing server node", http.MethodGet, base + "/serverGroupsv2/pg1/servers/nope" + ver, http.StatusNotFound}, + {"get missing configuration", http.MethodGet, base + "/serverGroupsv2/pg1/configurations/nope" + ver, http.StatusNotFound}, + {"method not allowed on coordinator config", http.MethodDelete, base + "/serverGroupsv2/pg1/coordinatorConfigurations/max_connections" + ver, http.StatusMethodNotAllowed}, + } + + for _, c := range cases { + if got := do(c.method, c.path, ""); got != c.want { + t.Errorf("%s: got %d, want %d", c.name, got, c.want) + } + } +} diff --git a/server/azure/cosmospostgresql/servers.go b/server/azure/cosmospostgresql/servers.go new file mode 100644 index 00000000..0649ca97 --- /dev/null +++ b/server/azure/cosmospostgresql/servers.go @@ -0,0 +1,60 @@ +package cosmospostgresql + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +func (h *Handler) serveServers(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + switch { + case rp.SubResourceName == "": + h.listServers(w, r, rp) + case rp.SubResourceAction == subConfigurations: + h.listServerConfigurations(w, r, rp) + default: + h.getServer(w, r, rp) + } +} + +func (h *Handler) listServers(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + servers, err := h.db.ListServers(r.Context(), rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, armListOf(servers, func(s *cpgdriver.Server) serverResource { + return toARMServer(s, h.childID(rp, subServers, s.Name)) + })) +} + +func (h *Handler) getServer(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + s, err := h.db.GetServer(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMServer(s, h.childID(rp, subServers, s.Name))) +} + +func (h *Handler) listServerConfigurations(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + cfgs, err := h.db.ListServerConfigurations(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, armListOf(cfgs, func(sc *cpgdriver.ServerConfiguration) serverConfigurationResource { + id := h.childID(rp, subServers, rp.SubResourceName) + "/" + subConfigurations + "/" + sc.Name + + return toARMServerConfiguration(sc, id, subConfigurations) + })) +} diff --git a/server/azure/cosmospostgresql/types.go b/server/azure/cosmospostgresql/types.go new file mode 100644 index 00000000..17cda43c --- /dev/null +++ b/server/azure/cosmospostgresql/types.go @@ -0,0 +1,418 @@ +package cosmospostgresql + +import ( + "fmt" + + cpgdriver "github.com/stackshy/cloudemu/v2/services/cosmospostgresql/driver" +) + +const ( + providerName = "Microsoft.DBforPostgreSQL" + resourceType = "serverGroupsv2" + + subFirewallRules = "firewallRules" + subRoles = "roles" + subServers = "servers" + subConfigurations = "configurations" + subCoordinatorCfgs = "coordinatorConfigurations" + subNodeCfgs = "nodeConfigurations" + subPrivateEPs = "privateEndpointConnections" + subPrivateLinks = "privateLinkResources" + + actionRestart = "restart" + actionStart = "start" + actionStop = "stop" + actionPromote = "promote" + + resourceCheckName = "checkNameAvailability" + resourceLocations = "locations" + subOperationStatuses = "operationStatuses" + + clusterResourceType = providerName + "/" + resourceType +) + +// armList is the ARM list-response envelope. +type armList[T any] struct { + Value []T `json:"value"` + NextLink string `json:"nextLink,omitempty"` +} + +type maintenanceWindow struct { + CustomWindow string `json:"customWindow,omitempty"` + DayOfWeek *int `json:"dayOfWeek,omitempty"` + StartHour *int `json:"startHour,omitempty"` + StartMinute *int `json:"startMinute,omitempty"` +} + +type serverNameItem struct { + Name string `json:"name,omitempty"` + FullyQualifiedDomainName string `json:"fullyQualifiedDomainName,omitempty"` +} + +// clusterResource is the ARM JSON shape for a server-group cluster. +type clusterResource struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Location string `json:"location,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + Properties *clusterProperties `json:"properties,omitempty"` +} + +type clusterProperties struct { + AdministratorLogin string `json:"administratorLogin,omitempty"` + AdministratorLoginPassword string `json:"administratorLoginPassword,omitempty"` + CitusVersion string `json:"citusVersion,omitempty"` + PostgresqlVersion string `json:"postgresqlVersion,omitempty"` + ProvisioningState string `json:"provisioningState,omitempty"` + State string `json:"state,omitempty"` + CoordinatorServerEdition string `json:"coordinatorServerEdition,omitempty"` + CoordinatorVCores *int `json:"coordinatorVCores,omitempty"` + CoordinatorStorageQuotaInMb *int `json:"coordinatorStorageQuotaInMb,omitempty"` + CoordinatorEnablePublicIPAccess *bool `json:"coordinatorEnablePublicIpAccess,omitempty"` + EnableShardsOnCoordinator *bool `json:"enableShardsOnCoordinator,omitempty"` + NodeServerEdition string `json:"nodeServerEdition,omitempty"` + NodeCount *int `json:"nodeCount,omitempty"` + NodeVCores *int `json:"nodeVCores,omitempty"` + NodeStorageQuotaInMb *int `json:"nodeStorageQuotaInMb,omitempty"` + NodeEnablePublicIPAccess *bool `json:"nodeEnablePublicIpAccess,omitempty"` + EnableHa *bool `json:"enableHa,omitempty"` + PreferredPrimaryZone string `json:"preferredPrimaryZone,omitempty"` + MaintenanceWindow *maintenanceWindow `json:"maintenanceWindow,omitempty"` + SourceResourceID string `json:"sourceResourceId,omitempty"` + SourceLocation string `json:"sourceLocation,omitempty"` + ReadReplicas []string `json:"readReplicas,omitempty"` + ServerNames []serverNameItem `json:"serverNames,omitempty"` +} + +type firewallRuleResource struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *firewallRuleProperties `json:"properties,omitempty"` +} + +type firewallRuleProperties struct { + ProvisioningState string `json:"provisioningState,omitempty"` + StartIPAddress string `json:"startIpAddress,omitempty"` + EndIPAddress string `json:"endIpAddress,omitempty"` +} + +type roleResource struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *roleProperties `json:"properties,omitempty"` +} + +type roleProperties struct { + Password string `json:"password,omitempty"` + ProvisioningState string `json:"provisioningState,omitempty"` +} + +type serverResource struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *serverProperties `json:"properties,omitempty"` +} + +type serverProperties struct { + AdministratorLogin string `json:"administratorLogin,omitempty"` + Role string `json:"role,omitempty"` + State string `json:"state,omitempty"` + HaState string `json:"haState,omitempty"` + FullyQualifiedDomainName string `json:"fullyQualifiedDomainName,omitempty"` + ServerEdition string `json:"serverEdition,omitempty"` + VCores int `json:"vCores,omitempty"` + StorageQuotaInMb int `json:"storageQuotaInMb,omitempty"` + CitusVersion string `json:"citusVersion,omitempty"` + PostgresqlVersion string `json:"postgresqlVersion,omitempty"` + EnableHa *bool `json:"enableHa,omitempty"` + EnablePublicIPAccess *bool `json:"enablePublicIpAccess,omitempty"` + IsReadOnly *bool `json:"isReadOnly,omitempty"` +} + +type configurationResource struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *configurationProperties `json:"properties,omitempty"` +} + +type configurationProperties struct { + ProvisioningState string `json:"provisioningState,omitempty"` + Description string `json:"description,omitempty"` + DataType string `json:"dataType,omitempty"` + AllowedValues string `json:"allowedValues,omitempty"` + RequiresRestart *bool `json:"requiresRestart,omitempty"` + ServerRoleGroupConfigurations []roleGroupConfig `json:"serverRoleGroupConfigurations,omitempty"` +} + +type roleGroupConfig struct { + Role string `json:"role,omitempty"` + Value string `json:"value,omitempty"` + DefaultValue string `json:"defaultValue,omitempty"` + Source string `json:"source,omitempty"` +} + +type serverConfigurationResource struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *serverConfigurationProperties `json:"properties,omitempty"` +} + +type serverConfigurationProperties struct { + Value string `json:"value,omitempty"` + DefaultValue string `json:"defaultValue,omitempty"` + Description string `json:"description,omitempty"` + DataType string `json:"dataType,omitempty"` + AllowedValues string `json:"allowedValues,omitempty"` + Source string `json:"source,omitempty"` + ProvisioningState string `json:"provisioningState,omitempty"` + RequiresRestart *bool `json:"requiresRestart,omitempty"` +} + +type privateEndpointConnectionResource struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *privateEndpointConnProps `json:"properties,omitempty"` +} + +type privateEndpointConnProps struct { + ProvisioningState string `json:"provisioningState,omitempty"` + GroupIDs []string `json:"groupIds,omitempty"` + PrivateEndpoint *privateEndpointRef `json:"privateEndpoint,omitempty"` + PrivateLinkServiceConnectionState *linkServiceConnState `json:"privateLinkServiceConnectionState,omitempty"` +} + +type privateEndpointRef struct { + ID string `json:"id,omitempty"` +} + +type linkServiceConnState struct { + Status string `json:"status,omitempty"` + Description string `json:"description,omitempty"` + ActionsRequired string `json:"actionsRequired,omitempty"` +} + +type privateLinkResource struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *privateLinkResourceProps `json:"properties,omitempty"` +} + +type privateLinkResourceProps struct { + GroupID string `json:"groupId,omitempty"` + RequiredMembers []string `json:"requiredMembers,omitempty"` + RequiredZoneNames []string `json:"requiredZoneNames,omitempty"` +} + +type nameAvailabilityRequest struct { + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` +} + +type nameAvailabilityResult struct { + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + NameAvailable *bool `json:"nameAvailable,omitempty"` + Message string `json:"message,omitempty"` +} + +func boolPtr(b bool) *bool { return &b } + +func intPtr(i int) *int { return &i } + +func cloneMW(in *cpgdriver.MaintenanceWindow) *maintenanceWindow { + if in == nil { + return nil + } + + return &maintenanceWindow{ + CustomWindow: in.CustomWindow, DayOfWeek: intPtr(in.DayOfWeek), + StartHour: intPtr(in.StartHour), StartMinute: intPtr(in.StartMinute), + } +} + +// nodeFQDN builds a node's fully-qualified domain name, matching the servers +// sub-resource: ..postgres.cosmos.azure.com. +func nodeFQDN(node, location string) string { + if location == "" { + location = "eastus" + } + + return node + "." + location + ".postgres.cosmos.azure.com" +} + +// serverNames enumerates the cluster's nodes (coordinator + workers), matching +// the servers sub-resource in both the set and the FQDN. +func serverNames(c *cpgdriver.Cluster) []serverNameItem { + coord := c.Name + "-c" + items := []serverNameItem{{Name: coord, FullyQualifiedDomainName: nodeFQDN(coord, c.Location)}} + + for i := 0; i < c.NodeCount; i++ { + w := fmt.Sprintf("%s-w%d", c.Name, i) + items = append(items, serverNameItem{Name: w, FullyQualifiedDomainName: nodeFQDN(w, c.Location)}) + } + + return items +} + +// toARMCluster converts a driver Cluster to ARM JSON. +func toARMCluster(c *cpgdriver.Cluster, id string) clusterResource { + return clusterResource{ + ID: id, + Name: c.Name, + Type: clusterResourceType, + Location: c.Location, + Tags: c.Tags, + Properties: &clusterProperties{ + AdministratorLogin: c.AdministratorLogin, + CitusVersion: c.CitusVersion, + PostgresqlVersion: c.PostgresqlVersion, + ProvisioningState: c.ProvisioningState, + State: c.State, + CoordinatorServerEdition: c.CoordinatorServerEdition, + CoordinatorVCores: intPtr(c.CoordinatorVCores), + CoordinatorStorageQuotaInMb: intPtr(c.CoordinatorStorageQuotaInMb), + CoordinatorEnablePublicIPAccess: boolPtr(c.CoordinatorEnablePublicIPAccess), + EnableShardsOnCoordinator: boolPtr(c.EnableShardsOnCoordinator), + NodeServerEdition: c.NodeServerEdition, + NodeCount: intPtr(c.NodeCount), + NodeVCores: intPtr(c.NodeVCores), + NodeStorageQuotaInMb: intPtr(c.NodeStorageQuotaInMb), + NodeEnablePublicIPAccess: boolPtr(c.NodeEnablePublicIPAccess), + EnableHa: boolPtr(c.EnableHa), + PreferredPrimaryZone: c.PreferredPrimaryZone, + MaintenanceWindow: cloneMW(c.MaintenanceWindow), + SourceResourceID: c.SourceResourceID, + SourceLocation: c.SourceLocation, + ReadReplicas: c.ReadReplicas, + ServerNames: serverNames(c), + }, + } +} + +func toARMFirewallRule(fr *cpgdriver.FirewallRule, id string) firewallRuleResource { + return firewallRuleResource{ + ID: id, + Name: fr.Name, + Type: clusterResourceType + "/" + subFirewallRules, + Properties: &firewallRuleProperties{ + ProvisioningState: fr.ProvisioningState, + StartIPAddress: fr.StartIPAddress, + EndIPAddress: fr.EndIPAddress, + }, + } +} + +func toARMRole(role *cpgdriver.Role, id string) roleResource { + return roleResource{ + ID: id, + Name: role.Name, + Type: clusterResourceType + "/" + subRoles, + Properties: &roleProperties{ProvisioningState: role.ProvisioningState}, + } +} + +func toARMServer(s *cpgdriver.Server, id string) serverResource { + return serverResource{ + ID: id, + Name: s.Name, + Type: clusterResourceType + "/" + subServers, + Properties: &serverProperties{ + AdministratorLogin: s.AdministratorLogin, + Role: s.Role, + State: s.State, + HaState: s.HaState, + FullyQualifiedDomainName: s.FullyQualifiedDomainName, + ServerEdition: s.ServerEdition, + VCores: s.VCores, + StorageQuotaInMb: s.StorageQuotaInMb, + CitusVersion: s.CitusVersion, + PostgresqlVersion: s.PostgresqlVersion, + EnableHa: boolPtr(s.EnableHa), + EnablePublicIPAccess: boolPtr(s.EnablePublicIPAccess), + IsReadOnly: boolPtr(s.IsReadOnly), + }, + } +} + +func toARMConfiguration(c *cpgdriver.Configuration, id string) configurationResource { + groups := make([]roleGroupConfig, 0, len(c.RoleGroups)) + + for i := range c.RoleGroups { + g := &c.RoleGroups[i] + groups = append(groups, roleGroupConfig{ + Role: g.Role, Value: g.Value, DefaultValue: g.DefaultValue, Source: g.Source, + }) + } + + return configurationResource{ + ID: id, + Name: c.Name, + Type: clusterResourceType + "/" + subConfigurations, + Properties: &configurationProperties{ + ProvisioningState: c.ProvisioningState, + Description: c.Description, + DataType: c.DataType, + AllowedValues: c.AllowedValues, + RequiresRestart: boolPtr(c.RequiresRestart), + ServerRoleGroupConfigurations: groups, + }, + } +} + +// toARMServerConfiguration renders a server-scoped configuration. typeSuffix is +// the collection segment used in the id (coordinatorConfigurations / +// nodeConfigurations / configurations) so type and id agree. +func toARMServerConfiguration(sc *cpgdriver.ServerConfiguration, id, typeSuffix string) serverConfigurationResource { + return serverConfigurationResource{ + ID: id, + Name: sc.Name, + Type: clusterResourceType + "/" + typeSuffix, + Properties: &serverConfigurationProperties{ + Value: sc.Value, + DefaultValue: sc.DefaultValue, + Description: sc.Description, + DataType: sc.DataType, + AllowedValues: sc.AllowedValues, + Source: sc.Source, + ProvisioningState: sc.ProvisioningState, + RequiresRestart: boolPtr(sc.RequiresRestart), + }, + } +} + +func toARMPrivateEndpointConnection(pec *cpgdriver.PrivateEndpointConnection, id string) privateEndpointConnectionResource { + return privateEndpointConnectionResource{ + ID: id, + Name: pec.Name, + Type: clusterResourceType + "/" + subPrivateEPs, + Properties: &privateEndpointConnProps{ + ProvisioningState: pec.ProvisioningState, + GroupIDs: pec.GroupIDs, + PrivateEndpoint: &privateEndpointRef{ID: pec.PrivateEndpointID}, + PrivateLinkServiceConnectionState: &linkServiceConnState{ + Status: pec.ConnectionStatus, Description: pec.ConnectionDesc, ActionsRequired: pec.ActionsRequired, + }, + }, + } +} + +func toARMPrivateLinkResource(plr *cpgdriver.PrivateLinkResource, id string) privateLinkResource { + return privateLinkResource{ + ID: id, + Name: plr.Name, + Type: clusterResourceType + "/" + subPrivateLinks, + Properties: &privateLinkResourceProps{ + GroupID: plr.GroupID, + RequiredMembers: plr.RequiredMembers, + RequiredZoneNames: plr.RequiredZoneNames, + }, + } +} diff --git a/server/azure/databricks/arm_accessconnectors_ops.go b/server/azure/databricks/arm_accessconnectors_ops.go new file mode 100644 index 00000000..c6f21342 --- /dev/null +++ b/server/azure/databricks/arm_accessconnectors_ops.go @@ -0,0 +1,128 @@ +package databricks + +import ( + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + dbxdriver "github.com/stackshy/cloudemu/v2/services/databricks/driver" +) + +// serveAccessConnectors routes accessConnectors collection and resource paths. +func (h *Handler) serveAccessConnectors(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if rp.ResourceName == "" { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + + return + } + + h.listAccessConnectors(w, r, rp) + + return + } + + switch r.Method { + case http.MethodPut: + h.createOrUpdateAccessConnector(w, r, rp) + case http.MethodGet: + h.getAccessConnector(w, r, rp) + case http.MethodPatch: + h.patchAccessConnector(w, r, rp) + case http.MethodDelete: + h.deleteAccessConnector(w, r, rp) + default: + writeMethodNotAllowed(w) + } +} + +func (h *Handler) createOrUpdateAccessConnector(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var body armAccessConnector + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := dbxdriver.AccessConnectorConfig{ + Name: rp.ResourceName, + ResourceGroup: rp.ResourceGroup, + Location: body.Location, + Tags: body.Tags, + Identity: fromARMIdentity(body.Identity), + } + + ac, err := h.dbx.CreateOrUpdateAccessConnector(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMAccessConnector(ac)) +} + +func (h *Handler) getAccessConnector(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + ac, err := h.dbx.GetAccessConnector(r.Context(), rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMAccessConnector(ac)) +} + +func (h *Handler) patchAccessConnector(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var body accessConnectorUpdate + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + ac, err := h.dbx.UpdateAccessConnector(r.Context(), rp.ResourceGroup, rp.ResourceName, body.Tags, fromARMIdentity(body.Identity)) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMAccessConnector(ac)) +} + +func (h *Handler) deleteAccessConnector(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + // ARM DELETE is idempotent: a missing resource is the caller's desired end + // state, so a NotFound from the driver still returns 204 (teardown retries + // and delete-then-delete must not fail on the second pass). + err := h.dbx.DeleteAccessConnector(r.Context(), rp.ResourceGroup, rp.ResourceName) + if err != nil && !cerrors.IsNotFound(err) { + azurearm.WriteCErr(w, err) + + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) listAccessConnectors(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var ( + connectors []dbxdriver.AccessConnector + err error + ) + + if rp.ResourceGroup != "" { + connectors, err = h.dbx.ListAccessConnectorsByResourceGroup(r.Context(), rp.ResourceGroup) + } else { + connectors, err = h.dbx.ListAccessConnectors(r.Context()) + } + + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + out := make([]armAccessConnector, 0, len(connectors)) + for i := range connectors { + out = append(out, toARMAccessConnector(&connectors[i])) + } + + azurearm.WriteJSON(w, http.StatusOK, armAccessConnectorList{Value: out}) +} diff --git a/server/azure/databricks/arm_accessconnectors_roundtrip_test.go b/server/azure/databricks/arm_accessconnectors_roundtrip_test.go new file mode 100644 index 00000000..97d5c611 --- /dev/null +++ b/server/azure/databricks/arm_accessconnectors_roundtrip_test.go @@ -0,0 +1,292 @@ +package databricks_test + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks" +) + +const accessConnectorType = "Microsoft.Databricks/accessConnectors" + +func newAccessConnectorsClient(t *testing.T) *armdatabricks.AccessConnectorsClient { + t.Helper() + + opts, sub := newARMOptions(t) + + client, err := armdatabricks.NewAccessConnectorsClient(sub, fakeCred{}, opts) + if err != nil { + t.Fatalf("new client: %v", err) + } + + return client +} + +func createAccessConnector( + t *testing.T, client *armdatabricks.AccessConnectorsClient, name string, ac armdatabricks.AccessConnector, +) armdatabricks.AccessConnector { + t.Helper() + + ctx := context.Background() + + poller, err := client.BeginCreateOrUpdate(ctx, testRG, name, ac, nil) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + res, err := poller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("PollUntilDone: %v", err) + } + + return res.AccessConnector +} + +func TestSDKAccessConnectorLifecycle(t *testing.T) { + client := newAccessConnectorsClient(t) + ctx := context.Background() + + const name = "conn-1" + + created := createAccessConnector(t, client, name, armdatabricks.AccessConnector{ + Location: to.Ptr("eastus"), + Identity: &armdatabricks.ManagedServiceIdentity{ + Type: to.Ptr(armdatabricks.ManagedServiceIdentityTypeSystemAssigned), + }, + Tags: map[string]*string{"env": to.Ptr("test")}, + }) + + if created.Name == nil || *created.Name != name { + t.Fatalf("got name %v, want %q", created.Name, name) + } + + if created.Type == nil || *created.Type != accessConnectorType { + t.Fatalf("got type %v, want %q", created.Type, accessConnectorType) + } + + if created.Properties == nil || created.Properties.ProvisioningState == nil || + *created.Properties.ProvisioningState != armdatabricks.ProvisioningStateSucceeded { + t.Fatalf("expected Succeeded provisioning state, got %+v", created.Properties) + } + + if created.Identity == nil { + t.Fatal("expected a system-assigned identity on create") + } + + if created.Identity.PrincipalID == nil || *created.Identity.PrincipalID == "" { + t.Fatalf("expected non-empty principal ID, got %v", created.Identity.PrincipalID) + } + + if created.Identity.TenantID == nil || *created.Identity.TenantID == "" { + t.Fatalf("expected non-empty tenant ID, got %v", created.Identity.TenantID) + } + + got, err := client.Get(ctx, testRG, name, nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Tags["env"] == nil || *got.Tags["env"] != "test" { + t.Fatalf("expected tag env=test, got %v", got.Tags) + } + + updatePoller, err := client.BeginUpdate(ctx, testRG, name, armdatabricks.AccessConnectorUpdate{ + Tags: map[string]*string{"env": to.Ptr("prod"), "team": to.Ptr("data")}, + }, nil) + if err != nil { + t.Fatalf("BeginUpdate: %v", err) + } + + updated, err := updatePoller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("update PollUntilDone: %v", err) + } + + if updated.Tags["env"] == nil || *updated.Tags["env"] != "prod" { + t.Fatalf("expected updated tag env=prod, got %v", updated.Tags) + } + + if updated.Tags["team"] == nil || *updated.Tags["team"] != "data" { + t.Fatalf("expected updated tag team=data, got %v", updated.Tags) + } + + byRG := client.NewListByResourceGroupPager(testRG, nil) + + rgCount := 0 + for byRG.More() { + page, perr := byRG.NextPage(ctx) + if perr != nil { + t.Fatalf("ListByResourceGroup: %v", perr) + } + + rgCount += len(page.Value) + } + + if rgCount != 1 { + t.Fatalf("got %d connectors in RG, want 1", rgCount) + } + + bySub := client.NewListBySubscriptionPager(nil) + + subCount := 0 + for bySub.More() { + page, perr := bySub.NextPage(ctx) + if perr != nil { + t.Fatalf("ListBySubscription: %v", perr) + } + + subCount += len(page.Value) + } + + if subCount != 1 { + t.Fatalf("got %d connectors in subscription, want 1", subCount) + } + + delPoller, err := client.BeginDelete(ctx, testRG, name, nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err = delPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("delete PollUntilDone: %v", err) + } + + if _, err = client.Get(ctx, testRG, name, nil); err == nil { + t.Fatal("expected error after delete") + } +} + +func TestSDKAccessConnectorGetNotFound(t *testing.T) { + client := newAccessConnectorsClient(t) + + _, err := client.Get(context.Background(), testRG, "does-not-exist", nil) + if err == nil { + t.Fatal("expected error for missing access connector") + } +} + +func TestSDKAccessConnectorEmptyLocation(t *testing.T) { + client := newAccessConnectorsClient(t) + ctx := context.Background() + + // Location is required; the emulator rejects it with InvalidArgument -> HTTP 400. + // The error may surface at BeginCreateOrUpdate or during PollUntilDone. + poller, err := client.BeginCreateOrUpdate(ctx, testRG, "conn-no-loc", armdatabricks.AccessConnector{ + Identity: &armdatabricks.ManagedServiceIdentity{ + Type: to.Ptr(armdatabricks.ManagedServiceIdentityTypeSystemAssigned), + }, + }, nil) + if err != nil { + return + } + + if _, err = poller.PollUntilDone(ctx, nil); err == nil { + t.Fatal("expected error creating connector with empty location") + } +} + +func TestSDKAccessConnectorListByResourceGroup(t *testing.T) { + client := newAccessConnectorsClient(t) + ctx := context.Background() + + names := []string{"conn-a", "conn-b"} + for _, name := range names { + createAccessConnector(t, client, name, armdatabricks.AccessConnector{ + Location: to.Ptr("eastus"), + }) + } + + byRG := client.NewListByResourceGroupPager(testRG, nil) + + got := map[string]bool{} + for byRG.More() { + page, err := byRG.NextPage(ctx) + if err != nil { + t.Fatalf("ListByResourceGroup: %v", err) + } + + for _, ac := range page.Value { + if ac.Name != nil { + got[*ac.Name] = true + } + } + } + + if len(got) != len(names) { + t.Fatalf("got %d connectors in RG, want %d (%v)", len(got), len(names), got) + } + + for _, name := range names { + if !got[name] { + t.Fatalf("expected connector %q in list, got %v", name, got) + } + } +} + +func TestSDKAccessConnectorNoIdentity(t *testing.T) { + client := newAccessConnectorsClient(t) + ctx := context.Background() + + // An identity Type of "None" resolves to no system identity in the provider. + created := createAccessConnector(t, client, "conn-none", armdatabricks.AccessConnector{ + Location: to.Ptr("eastus"), + Identity: &armdatabricks.ManagedServiceIdentity{ + Type: to.Ptr(armdatabricks.ManagedServiceIdentityTypeNone), + }, + }) + + if created.Identity != nil { + t.Fatalf("expected no identity for Type=None, got %+v", created.Identity) + } + + got, err := client.Get(ctx, testRG, "conn-none", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Identity != nil { + t.Fatalf("expected no identity on Get for Type=None, got %+v", got.Identity) + } +} + +// TestSDKAccessConnectorPatchIdentityToNone SDK-round-trips the identity +// transition the unit tests cover only in-process: a PATCH with identity type +// None clears a previously system-assigned identity. +func TestSDKAccessConnectorPatchIdentityToNone(t *testing.T) { + client := newAccessConnectorsClient(t) + ctx := context.Background() + + const name = "conn-none" + + created := createAccessConnector(t, client, name, armdatabricks.AccessConnector{ + Location: to.Ptr("eastus"), + Identity: &armdatabricks.ManagedServiceIdentity{ + Type: to.Ptr(armdatabricks.ManagedServiceIdentityTypeSystemAssigned), + }, + }) + + if created.Identity == nil || created.Identity.PrincipalID == nil || *created.Identity.PrincipalID == "" { + t.Fatalf("expected a system-assigned identity on create, got %+v", created.Identity) + } + + poller, err := client.BeginUpdate(ctx, testRG, name, armdatabricks.AccessConnectorUpdate{ + Identity: &armdatabricks.ManagedServiceIdentity{ + Type: to.Ptr(armdatabricks.ManagedServiceIdentityTypeNone), + }, + }, nil) + if err != nil { + t.Fatalf("BeginUpdate: %v", err) + } + + updated, err := poller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("update PollUntilDone: %v", err) + } + + // None clears the identity: the emulator resolves type None to no identity, + // so the ARM response omits the identity block. + if updated.Identity != nil { + t.Fatalf("expected identity cleared after PATCH None, got %+v", updated.Identity) + } +} diff --git a/server/azure/databricks/arm_delete_idempotent_test.go b/server/azure/databricks/arm_delete_idempotent_test.go new file mode 100644 index 00000000..465e31a7 --- /dev/null +++ b/server/azure/databricks/arm_delete_idempotent_test.go @@ -0,0 +1,108 @@ +package databricks_test + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks" +) + +// These tests prove that ARM DELETE is idempotent through the real armdatabricks +// clients: deleting an already-gone (or never-created) #209 resource must not +// error. The emulator answers such a delete with 204 No Content, so an SDK +// BeginDelete -> PollUntilDone reports success rather than surfacing a 404. + +// idempotentDeleteAC runs BeginDelete + PollUntilDone on an access connector and +// fails the test if either step returns an error. +func idempotentDeleteAC(t *testing.T, client *armdatabricks.AccessConnectorsClient, rg, name string) { + t.Helper() + + ctx := context.Background() + + poller, err := client.BeginDelete(ctx, rg, name, nil) + if err != nil { + t.Fatalf("BeginDelete(%q): %v", name, err) + } + + if _, err = poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("delete PollUntilDone(%q): %v", name, err) + } +} + +func TestSDKAccessConnectorDeleteIdempotent(t *testing.T) { + opts, sub := newARMOptions(t) + + client, err := armdatabricks.NewAccessConnectorsClient(sub, fakeCred{}, opts) + if err != nil { + t.Fatalf("new access connectors client: %v", err) + } + + ctx := context.Background() + + const connName = "conn-idempotent" + + // Create a connector so the first delete has a real target. + createPoller, err := client.BeginCreateOrUpdate(ctx, testRG, connName, armdatabricks.AccessConnector{ + Location: to.Ptr("eastus"), + }, nil) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + if _, err = createPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("create PollUntilDone: %v", err) + } + + // First delete removes it, second delete on the now-missing connector must + // still succeed (idempotent 204). + idempotentDeleteAC(t, client, testRG, connName) + idempotentDeleteAC(t, client, testRG, connName) + + // Deleting a connector that was never created must also succeed. + idempotentDeleteAC(t, client, testRG, "never-existed") +} + +func TestSDKPrivateEndpointDeleteIdempotent(t *testing.T) { + opts, sub := newARMOptions(t) + seedWorkspace(t, opts, testRG, testWS) + + client, err := armdatabricks.NewPrivateEndpointConnectionsClient(sub, fakeCred{}, opts) + if err != nil { + t.Fatalf("new PEC client: %v", err) + } + + ctx := context.Background() + + // Deleting a PEC that was never created on a live workspace must not error. + poller, err := client.BeginDelete(ctx, testRG, testWS, "never-existed", nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err = poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("delete PollUntilDone on missing PEC: %v", err) + } +} + +func TestSDKVNetPeeringDeleteIdempotent(t *testing.T) { + opts, sub := newARMOptions(t) + seedWorkspace(t, opts, testRG, testWS) + + client, err := armdatabricks.NewVNetPeeringClient(sub, fakeCred{}, opts) + if err != nil { + t.Fatalf("new vnet peering client: %v", err) + } + + ctx := context.Background() + + // Deleting a peering that was never created on a live workspace must not error. + poller, err := client.BeginDelete(ctx, testRG, testWS, "never-existed", nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err = poller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("delete PollUntilDone on missing peering: %v", err) + } +} diff --git a/server/azure/databricks/arm_network_ops.go b/server/azure/databricks/arm_network_ops.go new file mode 100644 index 00000000..09c873ab --- /dev/null +++ b/server/azure/databricks/arm_network_ops.go @@ -0,0 +1,271 @@ +package databricks + +import ( + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + dbxdriver "github.com/stackshy/cloudemu/v2/services/databricks/driver" +) + +// --- Private endpoint connections --- + +//nolint:dupl // parallel sub-resource dispatch; mirrors servePeering over a different collection +func (h *Handler) servePEC(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if rp.SubResourceName == "" { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + + return + } + + h.listPEC(w, r, rp) + + return + } + + switch r.Method { + case http.MethodPut: + h.putPEC(w, r, rp) + case http.MethodGet: + h.getPEC(w, r, rp) + case http.MethodDelete: + h.deletePEC(w, r, rp) + default: + writeMethodNotAllowed(w) + } +} + +func (h *Handler) putPEC(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var body armPEC + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + var status, description string + if body.Properties != nil && body.Properties.PrivateLinkServiceConnectionState != nil { + status = body.Properties.PrivateLinkServiceConnectionState.Status + description = body.Properties.PrivateLinkServiceConnectionState.Description + } + + c, err := h.dbx.PutPrivateEndpointConnection(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName, status, description) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMPEC(c)) +} + +func (h *Handler) getPEC(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + c, err := h.dbx.GetPrivateEndpointConnection(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMPEC(c)) +} + +func (h *Handler) deletePEC(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + // ARM DELETE is idempotent: a missing resource is the caller's desired end + // state, so a NotFound from the driver still returns 204 (teardown retries + // and delete-then-delete must not fail on the second pass). + err := h.dbx.DeletePrivateEndpointConnection(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName) + if err != nil && !cerrors.IsNotFound(err) { + azurearm.WriteCErr(w, err) + + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) listPEC(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + cs, err := h.dbx.ListPrivateEndpointConnections(r.Context(), rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + out := make([]armPEC, 0, len(cs)) + for i := range cs { + out = append(out, toARMPEC(&cs[i])) + } + + azurearm.WriteJSON(w, http.StatusOK, armPECList{Value: out}) +} + +// --- Private link resources (read-only) --- + +func (h *Handler) servePLR(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + + return + } + + if rp.SubResourceName == "" { + h.listPLR(w, r, rp) + + return + } + + g, err := h.dbx.GetPrivateLinkResource(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMGroupIDInformation(g)) +} + +func (h *Handler) listPLR(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + gs, err := h.dbx.ListPrivateLinkResources(r.Context(), rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + out := make([]armGroupIDInformation, 0, len(gs)) + for i := range gs { + out = append(out, toARMGroupIDInformation(&gs[i])) + } + + azurearm.WriteJSON(w, http.StatusOK, armPLRList{Value: out}) +} + +// --- Virtual network peerings --- + +//nolint:dupl // parallel sub-resource dispatch; mirrors servePEC over a different collection +func (h *Handler) servePeering(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if rp.SubResourceName == "" { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + + return + } + + h.listPeering(w, r, rp) + + return + } + + switch r.Method { + case http.MethodPut: + h.putPeering(w, r, rp) + case http.MethodGet: + h.getPeering(w, r, rp) + case http.MethodDelete: + h.deletePeering(w, r, rp) + default: + writeMethodNotAllowed(w) + } +} + +func (h *Handler) putPeering(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var body armPeering + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + cfg := dbxdriver.VirtualNetworkPeeringConfig{} + if p := body.Properties; p != nil { + cfg.AllowForwardedTraffic = p.AllowForwardedTraffic + cfg.AllowGatewayTransit = p.AllowGatewayTransit + cfg.AllowVirtualNetworkAccess = p.AllowVirtualNetworkAccess + cfg.UseRemoteGateways = p.UseRemoteGateways + cfg.DatabricksAddressSpace = fromARMAddressSpace(p.DatabricksAddressSpace) + cfg.RemoteAddressSpace = fromARMAddressSpace(p.RemoteAddressSpace) + + if p.DatabricksVirtualNetwork != nil { + cfg.DatabricksVNetID = p.DatabricksVirtualNetwork.ID + } + + if p.RemoteVirtualNetwork != nil { + cfg.RemoteVNetID = p.RemoteVirtualNetwork.ID + } + } + + peer, err := h.dbx.CreateOrUpdateVNetPeering(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName, cfg) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMPeering(peer)) +} + +func (h *Handler) getPeering(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + peer, err := h.dbx.GetVNetPeering(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + azurearm.WriteJSON(w, http.StatusOK, toARMPeering(peer)) +} + +func (h *Handler) deletePeering(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + // ARM DELETE is idempotent: a missing resource is the caller's desired end + // state, so a NotFound from the driver still returns 204 (teardown retries + // and delete-then-delete must not fail on the second pass). + err := h.dbx.DeleteVNetPeering(r.Context(), rp.ResourceGroup, rp.ResourceName, rp.SubResourceName) + if err != nil && !cerrors.IsNotFound(err) { + azurearm.WriteCErr(w, err) + + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) listPeering(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + ps, err := h.dbx.ListVNetPeerings(r.Context(), rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + out := make([]armPeering, 0, len(ps)) + for i := range ps { + out = append(out, toARMPeering(&ps[i])) + } + + azurearm.WriteJSON(w, http.StatusOK, armPeeringList{Value: out}) +} + +// --- Outbound network dependencies (read-only list) --- + +func (h *Handler) serveOutbound(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + + return + } + + eps, err := h.dbx.ListOutboundNetworkDependencies(r.Context(), rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + out := make([]armOutboundEndpoint, 0, len(eps)) + for i := range eps { + out = append(out, toARMOutbound(&eps[i])) + } + + // The armdatabricks OutboundNetworkDependenciesEndpoints List response is a + // bare JSON array (the SDK unmarshals the body straight into a slice), not a + // {"value":[...]} envelope like the other list endpoints. + azurearm.WriteJSON(w, http.StatusOK, out) +} diff --git a/server/azure/databricks/arm_operations_ops.go b/server/azure/databricks/arm_operations_ops.go new file mode 100644 index 00000000..6611c540 --- /dev/null +++ b/server/azure/databricks/arm_operations_ops.go @@ -0,0 +1,30 @@ +package databricks + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" +) + +// serveOperations handles GET /providers/Microsoft.Databricks/operations. +func (h *Handler) serveOperations(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + + return + } + + ops, err := h.dbx.ListOperations(r.Context()) + if err != nil { + azurearm.WriteCErr(w, err) + + return + } + + out := make([]armOperation, 0, len(ops)) + for i := range ops { + out = append(out, toARMOperation(&ops[i])) + } + + azurearm.WriteJSON(w, http.StatusOK, armOperationList{Value: out}) +} diff --git a/server/azure/databricks/arm_operations_roundtrip_test.go b/server/azure/databricks/arm_operations_roundtrip_test.go new file mode 100644 index 00000000..3ff9940e --- /dev/null +++ b/server/azure/databricks/arm_operations_roundtrip_test.go @@ -0,0 +1,99 @@ +package databricks_test + +import ( + "context" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks" +) + +const ( + opNamePrefix = "Microsoft.Databricks/" + opProvider = "Microsoft.Databricks" +) + +// listOperations exercises the subscription-less provider operations list end to +// end via the real armdatabricks OperationsClient. Note the client constructor +// takes NO subscription id: the SDK hits GET /providers/Microsoft.Databricks/operations +// with no /subscriptions/{sub} prefix, which the emulator handler special-cases. +func listOperations(t *testing.T) []*armdatabricks.Operation { + t.Helper() + + opts, _ := newARMOptions(t) + + client, err := armdatabricks.NewOperationsClient(fakeCred{}, opts) + if err != nil { + t.Fatalf("new operations client: %v", err) + } + + ctx := context.Background() + pager := client.NewListPager(nil) + + var ops []*armdatabricks.Operation + + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("NextPage: %v", err) + } + + ops = append(ops, page.Value...) + } + + return ops +} + +func TestSDKOperationsList(t *testing.T) { + ops := listOperations(t) + + if len(ops) == 0 { + t.Fatal("expected a non-empty operations catalog") + } + + for i, o := range ops { + if o == nil { + t.Fatalf("operation %d is nil", i) + } + + if o.Name == nil || !strings.HasPrefix(*o.Name, opNamePrefix) { + t.Fatalf("operation %d: name %v missing prefix %q", i, o.Name, opNamePrefix) + } + + if o.Display == nil { + t.Fatalf("operation %d (%s): missing display", i, *o.Name) + } + + if o.Display.Provider == nil || *o.Display.Provider != opProvider { + t.Fatalf("operation %d (%s): got provider %v, want %q", i, *o.Name, o.Display.Provider, opProvider) + } + + if o.Display.Operation == nil || *o.Display.Operation == "" { + t.Fatalf("operation %d (%s): empty display operation", i, *o.Name) + } + } +} + +func TestSDKOperationsCatalogContainsKnownEntries(t *testing.T) { + ops := listOperations(t) + + got := make(map[string]bool, len(ops)) + + for _, o := range ops { + if o != nil && o.Name != nil { + got[*o.Name] = true + } + } + + want := []string{ + "Microsoft.Databricks/workspaces/read", + "Microsoft.Databricks/workspaces/write", + "Microsoft.Databricks/accessConnectors/write", + } + + for _, name := range want { + if !got[name] { + t.Errorf("operations catalog missing %q", name) + } + } +} diff --git a/server/azure/databricks/arm_outbound_roundtrip_test.go b/server/azure/databricks/arm_outbound_roundtrip_test.go new file mode 100644 index 00000000..75fcaf4d --- /dev/null +++ b/server/azure/databricks/arm_outbound_roundtrip_test.go @@ -0,0 +1,96 @@ +package databricks_test + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks" +) + +// controlPlaneCategory is one of the static categories the emulator synthesizes +// for a workspace's outbound network dependencies (see the provider's +// ListOutboundNetworkDependencies). +const controlPlaneCategory = "control-plane" + +func newOutboundClient(t *testing.T) *armdatabricks.OutboundNetworkDependenciesEndpointsClient { + t.Helper() + + opts, sub := newARMOptions(t) + seedWorkspace(t, opts, testRG, testWS) + + client, err := armdatabricks.NewOutboundNetworkDependenciesEndpointsClient(sub, fakeCred{}, opts) + if err != nil { + t.Fatalf("new client: %v", err) + } + + return client +} + +func TestSDKOutboundList(t *testing.T) { + client := newOutboundClient(t) + + resp, err := client.List(context.Background(), testRG, testWS, nil) + if err != nil { + t.Fatalf("List: %v", err) + } + + if len(resp.OutboundEnvironmentEndpointArray) == 0 { + t.Fatal("expected a non-empty outbound endpoint array") + } + + foundControlPlane := false + + for i, ep := range resp.OutboundEnvironmentEndpointArray { + if ep == nil { + t.Fatalf("outbound endpoint %d is nil", i) + } + + if ep.Category == nil || *ep.Category == "" { + t.Fatalf("outbound endpoint %d has an empty category", i) + } + + if *ep.Category == controlPlaneCategory { + foundControlPlane = true + } + + if len(ep.Endpoints) == 0 { + t.Fatalf("category %q has no endpoints", *ep.Category) + } + + hasDomainWithHTTPS := false + + for _, dep := range ep.Endpoints { + if dep == nil || dep.DomainName == nil || *dep.DomainName == "" { + continue + } + + for _, detail := range dep.EndpointDetails { + if detail != nil && detail.Port != nil && *detail.Port == 443 { + hasDomainWithHTTPS = true + } + } + } + + if !hasDomainWithHTTPS { + t.Fatalf("category %q has no domain with a port 443 endpoint detail", *ep.Category) + } + } + + if !foundControlPlane { + t.Fatalf("expected category %q in the outbound endpoints", controlPlaneCategory) + } +} + +func TestSDKOutboundListWorkspaceNotFound(t *testing.T) { + opts, sub := newARMOptions(t) + + client, err := armdatabricks.NewOutboundNetworkDependenciesEndpointsClient(sub, fakeCred{}, opts) + if err != nil { + t.Fatalf("new client: %v", err) + } + + _, err = client.List(context.Background(), testRG, "does-not-exist", nil) + if err == nil { + t.Fatal("expected error for missing workspace") + } +} diff --git a/server/azure/databricks/arm_privateendpoints_roundtrip_test.go b/server/azure/databricks/arm_privateendpoints_roundtrip_test.go new file mode 100644 index 00000000..e580630e --- /dev/null +++ b/server/azure/databricks/arm_privateendpoints_roundtrip_test.go @@ -0,0 +1,196 @@ +package databricks_test + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks" +) + +const wantPECType = "Microsoft.Databricks/workspaces/privateEndpointConnections" + +func newPECClient(t *testing.T) *armdatabricks.PrivateEndpointConnectionsClient { + t.Helper() + + opts, sub := newARMOptions(t) + + seedWorkspace(t, opts, testRG, testWS) + + client, err := armdatabricks.NewPrivateEndpointConnectionsClient(sub, fakeCred{}, opts) + if err != nil { + t.Fatalf("new PEC client: %v", err) + } + + return client +} + +func createPEC( + t *testing.T, + client *armdatabricks.PrivateEndpointConnectionsClient, + name string, + status armdatabricks.PrivateLinkServiceConnectionStatus, + description string, +) armdatabricks.PrivateEndpointConnection { + t.Helper() + + ctx := context.Background() + + poller, err := client.BeginCreate(ctx, testRG, testWS, name, armdatabricks.PrivateEndpointConnection{ + Properties: &armdatabricks.PrivateEndpointConnectionProperties{ + PrivateLinkServiceConnectionState: &armdatabricks.PrivateLinkServiceConnectionState{ + Status: to.Ptr(status), + Description: to.Ptr(description), + }, + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreate: %v", err) + } + + res, err := poller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("create PollUntilDone: %v", err) + } + + return res.PrivateEndpointConnection +} + +func hasGroupID(groups []*string, want string) bool { + for _, g := range groups { + if g != nil && *g == want { + return true + } + } + + return false +} + +func TestSDKPrivateEndpointLifecycle(t *testing.T) { + client := newPECClient(t) + ctx := context.Background() + + const pecName = "my-pec" + + created := createPEC(t, client, pecName, armdatabricks.PrivateLinkServiceConnectionStatusApproved, "ok") + + if created.Name == nil || *created.Name != pecName { + t.Fatalf("got name %v, want %q", created.Name, pecName) + } + + if created.Type == nil || *created.Type != wantPECType { + t.Fatalf("got type %v, want %q", created.Type, wantPECType) + } + + if created.Properties == nil { + t.Fatal("expected properties on created PEC") + } + + state := created.Properties.PrivateLinkServiceConnectionState + if state == nil || state.Status == nil || + *state.Status != armdatabricks.PrivateLinkServiceConnectionStatusApproved { + t.Fatalf("expected Approved status, got %+v", state) + } + + if created.Properties.ProvisioningState == nil || + *created.Properties.ProvisioningState != armdatabricks.PrivateEndpointConnectionProvisioningStateSucceeded { + t.Fatalf("expected Succeeded provisioning state, got %v", created.Properties.ProvisioningState) + } + + if !hasGroupID(created.Properties.GroupIDs, "databricks_ui_api") { + t.Fatalf("expected GroupIDs to contain databricks_ui_api, got %v", created.Properties.GroupIDs) + } + + got, err := client.Get(ctx, testRG, testWS, pecName, nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Name == nil || *got.Name != pecName { + t.Fatalf("Get returned name %v, want %q", got.Name, pecName) + } + + pager := client.NewListPager(testRG, testWS, nil) + + var count int + + for pager.More() { + page, errPage := pager.NextPage(ctx) + if errPage != nil { + t.Fatalf("List NextPage: %v", errPage) + } + + count += len(page.Value) + } + + if count != 1 { + t.Fatalf("got %d PECs in list, want 1", count) + } + + delPoller, err := client.BeginDelete(ctx, testRG, testWS, pecName, nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err = delPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("delete PollUntilDone: %v", err) + } + + if _, err = client.Get(ctx, testRG, testWS, pecName, nil); err == nil { + t.Fatal("expected error getting PEC after delete") + } +} + +func TestSDKPrivateEndpointMissingWorkspace(t *testing.T) { + // Do not seed a workspace: build the client directly against a fresh server. + opts, sub := newARMOptions(t) + + client, err := armdatabricks.NewPrivateEndpointConnectionsClient(sub, fakeCred{}, opts) + if err != nil { + t.Fatalf("new PEC client: %v", err) + } + + ctx := context.Background() + + poller, err := client.BeginCreate(ctx, testRG, "no-such-workspace", "pec", armdatabricks.PrivateEndpointConnection{ + Properties: &armdatabricks.PrivateEndpointConnectionProperties{ + PrivateLinkServiceConnectionState: &armdatabricks.PrivateLinkServiceConnectionState{ + Status: to.Ptr(armdatabricks.PrivateLinkServiceConnectionStatusApproved), + }, + }, + }, nil) + if err == nil { + if _, err = poller.PollUntilDone(ctx, nil); err == nil { + t.Fatal("expected error creating PEC on a missing workspace") + } + } + + pager := client.NewListPager(testRG, "no-such-workspace", nil) + if _, err = pager.NextPage(ctx); err == nil { + t.Fatal("expected error listing PECs on a missing workspace") + } +} + +func TestSDKPrivateEndpointGetMissing(t *testing.T) { + client := newPECClient(t) + + _, err := client.Get(context.Background(), testRG, testWS, "does-not-exist", nil) + if err == nil { + t.Fatal("expected error getting a missing PEC") + } +} + +func TestSDKPrivateEndpointRejectedStatus(t *testing.T) { + client := newPECClient(t) + + created := createPEC(t, client, "rejected-pec", armdatabricks.PrivateLinkServiceConnectionStatusRejected, "nope") + + if created.Properties == nil || created.Properties.PrivateLinkServiceConnectionState == nil { + t.Fatalf("expected connection state, got %+v", created.Properties) + } + + state := created.Properties.PrivateLinkServiceConnectionState + if state.Status == nil || *state.Status != armdatabricks.PrivateLinkServiceConnectionStatusRejected { + t.Fatalf("expected Rejected status echoed back, got %+v", state) + } +} diff --git a/server/azure/databricks/arm_privatelinkresources_roundtrip_test.go b/server/azure/databricks/arm_privatelinkresources_roundtrip_test.go new file mode 100644 index 00000000..37c59330 --- /dev/null +++ b/server/azure/databricks/arm_privatelinkresources_roundtrip_test.go @@ -0,0 +1,148 @@ +package databricks_test + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks" +) + +// newPLRoundtripClient builds a PrivateLinkResourcesClient wired to the emulator. +func newPLRoundtripClient(t *testing.T) *armdatabricks.PrivateLinkResourcesClient { + t.Helper() + + opts, sub := newARMOptions(t) + + client, err := armdatabricks.NewPrivateLinkResourcesClient(sub, fakeCred{}, opts) + if err != nil { + t.Fatalf("new client: %v", err) + } + + return client +} + +func TestSDKPrivateLinkResourceList(t *testing.T) { + opts, sub := newARMOptions(t) + seedWorkspace(t, opts, testRG, testWS) + + client, err := armdatabricks.NewPrivateLinkResourcesClient(sub, fakeCred{}, opts) + if err != nil { + t.Fatalf("new client: %v", err) + } + + ctx := context.Background() + + pager := client.NewListPager(testRG, testWS, nil) + + var got []*armdatabricks.GroupIDInformation + + for pager.More() { + page, perr := pager.NextPage(ctx) + if perr != nil { + t.Fatalf("NextPage: %v", perr) + } + + got = append(got, page.Value...) + } + + if len(got) != 2 { + t.Fatalf("got %d private link resources, want 2", len(got)) + } + + seen := map[string]*armdatabricks.GroupIDInformation{} + + for _, g := range got { + if g.Properties == nil || g.Properties.GroupID == nil { + t.Fatalf("resource %+v missing GroupID", g) + } + + seen[*g.Properties.GroupID] = g + } + + for _, want := range []string{"databricks_ui_api", "browser_authentication"} { + g, ok := seen[want] + if !ok { + t.Fatalf("group id %q not present in list; got %v", want, plrGroupKeys(seen)) + } + + if len(g.Properties.RequiredMembers) == 0 { + t.Fatalf("group id %q has empty RequiredMembers", want) + } + + if len(g.Properties.RequiredZoneNames) == 0 { + t.Fatalf("group id %q has empty RequiredZoneNames", want) + } + } +} + +func TestSDKPrivateLinkResourceGet(t *testing.T) { + opts, sub := newARMOptions(t) + seedWorkspace(t, opts, testRG, testWS) + + client, err := armdatabricks.NewPrivateLinkResourcesClient(sub, fakeCred{}, opts) + if err != nil { + t.Fatalf("new client: %v", err) + } + + resp, err := client.Get(context.Background(), testRG, testWS, "databricks_ui_api", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + g := resp.GroupIDInformation + + if g.Name == nil || *g.Name != "databricks_ui_api" { + t.Fatalf("got name %v, want databricks_ui_api", g.Name) + } + + if g.Properties == nil || g.Properties.GroupID == nil || *g.Properties.GroupID != "databricks_ui_api" { + t.Fatalf("got group id %+v, want databricks_ui_api", g.Properties) + } + + if len(g.Properties.RequiredZoneNames) == 0 { + t.Fatal("expected RequiredZoneNames to be present") + } + + if *g.Properties.RequiredZoneNames[0] != "privatelink.azuredatabricks.net" { + t.Fatalf("got zone %q, want privatelink.azuredatabricks.net", *g.Properties.RequiredZoneNames[0]) + } +} + +func TestSDKPrivateLinkResourceGetUnknown(t *testing.T) { + opts, sub := newARMOptions(t) + seedWorkspace(t, opts, testRG, testWS) + + client, err := armdatabricks.NewPrivateLinkResourcesClient(sub, fakeCred{}, opts) + if err != nil { + t.Fatalf("new client: %v", err) + } + + if _, err := client.Get(context.Background(), testRG, testWS, "nope", nil); err == nil { + t.Fatal("expected error for unknown group id") + } +} + +func TestSDKPrivateLinkResourceMissingWorkspace(t *testing.T) { + client := newPLRoundtripClient(t) + ctx := context.Background() + + if _, err := client.Get(ctx, testRG, "does-not-exist", "databricks_ui_api", nil); err == nil { + t.Fatal("expected error getting private link resource on missing workspace") + } + + pager := client.NewListPager(testRG, "does-not-exist", nil) + + if _, err := pager.NextPage(ctx); err == nil { + t.Fatal("expected error listing private link resources on missing workspace") + } +} + +// plrGroupKeys returns the map keys, used for readable failure messages. +func plrGroupKeys(m map[string]*armdatabricks.GroupIDInformation) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + + return out +} diff --git a/server/azure/databricks/arm_routing_test.go b/server/azure/databricks/arm_routing_test.go new file mode 100644 index 00000000..438bde39 --- /dev/null +++ b/server/azure/databricks/arm_routing_test.go @@ -0,0 +1,99 @@ +package databricks_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stackshy/cloudemu/v2" + azureserver "github.com/stackshy/cloudemu/v2/server/azure" + dbxdriver "github.com/stackshy/cloudemu/v2/services/databricks/driver" +) + +// TestARMRoutingCaseInsensitive proves that the Microsoft.Databricks ARM handler +// routes on the provider namespace and resource-type segments case-insensitively, +// matching ARM's own semantics. The armdatabricks SDK always emits canonical +// casing, so these assertions issue RAW HTTP requests to bypass it. +func TestARMRoutingCaseInsensitive(t *testing.T) { + cloudP := cloudemu.NewAzure() + srv := azureserver.New(azureserver.Drivers{Databricks: cloudP.Databricks}) + + ts := httptest.NewTLSServer(srv) + defer ts.Close() + + const ( + routeSub = "sub-1" + routeRG = "rg-1" + routeAC = "ac1" + ) + + if _, err := cloudP.Databricks.CreateOrUpdateAccessConnector(context.Background(), dbxdriver.AccessConnectorConfig{ + Name: routeAC, + ResourceGroup: routeRG, + Location: "eastus", + }); err != nil { + t.Fatalf("seed access connector: %v", err) + } + + base := ts.URL + "/subscriptions/" + routeSub + "/resourceGroups/" + routeRG + "/providers/" + + tests := []struct { + name string + path string + want int + }{ + { + // Lowercased provider AND resource-type segments. Before the fix this + // 404'd because matching was case-sensitive; ARM treats both segments + // case-insensitively, so this must resolve to the seeded connector. + name: "lowercased provider and resource type", + path: base + "microsoft.databricks/accessconnectors/" + routeAC + "?api-version=2023-02-01", + want: http.StatusOK, + }, + { + // Canonical casing (what the SDK emits) must keep behaving exactly as + // before the fix. + name: "canonical casing", + path: base + "Microsoft.Databricks/accessConnectors/" + routeAC + "?api-version=2023-02-01", + want: http.StatusOK, + }, + { + // A genuinely unknown provider matches no handler, so the server's + // dispatcher rejects it (501 Not Implemented) rather than routing it + // into the Databricks handler. + name: "unknown provider is not routed to databricks", + path: base + "Microsoft.NotDatabricks/accessConnectors/" + routeAC + "?api-version=2023-02-01", + want: http.StatusNotImplemented, + }, + { + // A known provider + workspaces resource but an unrecognized + // sub-resource must still hit the handler's default 404 branch, which + // the case-insensitive refactor must leave intact. + name: "unknown workspace sub-resource 404s", + path: base + "Microsoft.Databricks/workspaces/ws-1/bogusSubResource?api-version=2023-02-01", + want: http.StatusNotFound, + }, + } + + client := ts.Client() + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, tc.path, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != tc.want { + t.Fatalf("GET %s: status = %d, want %d", tc.path, resp.StatusCode, tc.want) + } + }) + } +} diff --git a/server/azure/databricks/arm_setup_test.go b/server/azure/databricks/arm_setup_test.go new file mode 100644 index 00000000..d89de190 --- /dev/null +++ b/server/azure/databricks/arm_setup_test.go @@ -0,0 +1,82 @@ +package databricks_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks" + + "github.com/stackshy/cloudemu/v2" + azureserver "github.com/stackshy/cloudemu/v2/server/azure" +) + +const testSub = "sub-1" + +// newARMOptions spins up an httptest server backed by a fresh Azure Databricks +// provider and returns arm client options + the subscription id pointing at it. +// Callers build any armdatabricks client (Workspaces, AccessConnectors, +// PrivateEndpointConnections, PrivateLinkResources, VNetPeering, +// OutboundNetworkDependenciesEndpoints, Operations) against the same server, so +// sub-resource tests can seed a workspace and then exercise their own client. +func newARMOptions(t *testing.T) (*arm.ClientOptions, string) { + t.Helper() + + cloudP := cloudemu.NewAzure() + srv := azureserver.New(azureserver.Drivers{Databricks: cloudP.Databricks}) + + ts := httptest.NewTLSServer(srv) + t.Cleanup(ts.Close) + + myCloud := cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/", + Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ + cloud.ResourceManager: {Endpoint: ts.URL, Audience: "https://management.azure.com"}, + }, + } + + return &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Cloud: myCloud, + Transport: ts.Client(), + Retry: policy.RetryOptions{MaxRetries: -1}, + }, + }, testSub +} + +// seedWorkspace creates a workspace via the real WorkspacesClient so that +// workspace sub-resource tests (PEC, private link, peering, outbound) have a +// live parent. It returns the created workspace. +func seedWorkspace(t *testing.T, opts *arm.ClientOptions, rg, name string) armdatabricks.Workspace { + t.Helper() + + client, err := armdatabricks.NewWorkspacesClient(testSub, fakeCred{}, opts) + if err != nil { + t.Fatalf("new workspaces client: %v", err) + } + + ctx := context.Background() + + poller, err := client.BeginCreateOrUpdate(ctx, rg, name, armdatabricks.Workspace{ + Location: to.Ptr("eastus"), + SKU: &armdatabricks.SKU{Name: to.Ptr("premium")}, + Properties: &armdatabricks.WorkspaceProperties{ + ManagedResourceGroupID: to.Ptr(managed), + }, + }, nil) + if err != nil { + t.Fatalf("seed BeginCreateOrUpdate: %v", err) + } + + res, err := poller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("seed PollUntilDone: %v", err) + } + + return res.Workspace +} diff --git a/server/azure/databricks/arm_types.go b/server/azure/databricks/arm_types.go new file mode 100644 index 00000000..a180acbb --- /dev/null +++ b/server/azure/databricks/arm_types.go @@ -0,0 +1,322 @@ +package databricks + +import dbxdriver "github.com/stackshy/cloudemu/v2/services/databricks/driver" + +// JSON wire shapes for the extended Microsoft.Databricks ARM surface (#209). +// Field names match what the real armdatabricks client emits and expects. + +// --- Access connectors --- + +type armAccessConnector struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Location string `json:"location,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + Identity *armIdentity `json:"identity,omitempty"` + Properties *accessConnectorProps `json:"properties,omitempty"` +} + +type accessConnectorProps struct { + ProvisioningState string `json:"provisioningState,omitempty"` +} + +// armIdentity is the ARM managed-service-identity envelope. +type armIdentity struct { + Type string `json:"type,omitempty"` + PrincipalID string `json:"principalId,omitempty"` + TenantID string `json:"tenantId,omitempty"` + UserAssignedIdentities map[string]*armUserAssigned `json:"userAssignedIdentities,omitempty"` +} + +type armUserAssigned struct { + PrincipalID string `json:"principalId,omitempty"` + ClientID string `json:"clientId,omitempty"` +} + +// accessConnectorUpdate is the PATCH body (tags and/or identity). +type accessConnectorUpdate struct { + Tags map[string]string `json:"tags,omitempty"` + Identity *armIdentity `json:"identity,omitempty"` +} + +type armAccessConnectorList struct { + Value []armAccessConnector `json:"value"` + NextLink string `json:"nextLink,omitempty"` +} + +func toARMAccessConnector(ac *dbxdriver.AccessConnector) armAccessConnector { + out := armAccessConnector{ + ID: ac.ID, + Name: ac.Name, + Type: providerName + "/" + accessConnectorsType, + Location: ac.Location, + Tags: ac.Tags, + Identity: toARMIdentity(ac.Identity), + Properties: &accessConnectorProps{ + ProvisioningState: ac.ProvisioningState, + }, + } + + return out +} + +func toARMIdentity(id *dbxdriver.ManagedIdentity) *armIdentity { + if id == nil { + return nil + } + + out := &armIdentity{ + Type: id.Type, + PrincipalID: id.PrincipalID, + TenantID: id.TenantID, + } + + if len(id.UserAssigned) > 0 { + out.UserAssignedIdentities = make(map[string]*armUserAssigned, len(id.UserAssigned)) + for _, u := range id.UserAssigned { + out.UserAssignedIdentities[u] = &armUserAssigned{} + } + } + + return out +} + +// fromARMIdentity converts an inbound ARM identity to the driver shape. +func fromARMIdentity(id *armIdentity) *dbxdriver.ManagedIdentity { + if id == nil { + return nil + } + + out := &dbxdriver.ManagedIdentity{Type: id.Type} + for k := range id.UserAssignedIdentities { + out.UserAssigned = append(out.UserAssigned, k) + } + + return out +} + +// --- Private endpoint connections --- + +type armPEC struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *pecProps `json:"properties,omitempty"` +} + +type pecProps struct { + PrivateEndpoint *armSubResource `json:"privateEndpoint,omitempty"` + PrivateLinkServiceConnectionState *plsConnState `json:"privateLinkServiceConnectionState,omitempty"` + GroupIDs []string `json:"groupIds,omitempty"` + ProvisioningState string `json:"provisioningState,omitempty"` +} + +type armSubResource struct { + ID string `json:"id,omitempty"` +} + +type plsConnState struct { + Status string `json:"status,omitempty"` + Description string `json:"description,omitempty"` + ActionsRequired string `json:"actionsRequired,omitempty"` +} + +type armPECList struct { + Value []armPEC `json:"value"` + NextLink string `json:"nextLink,omitempty"` +} + +func toARMPEC(c *dbxdriver.PrivateEndpointConnection) armPEC { + props := &pecProps{ + GroupIDs: c.GroupIDs, + ProvisioningState: c.ProvisioningState, + PrivateLinkServiceConnectionState: &plsConnState{ + Status: c.Status, + Description: c.Description, + ActionsRequired: c.ActionsRequired, + }, + } + if c.PrivateEndpointID != "" { + props.PrivateEndpoint = &armSubResource{ID: c.PrivateEndpointID} + } + + return armPEC{ + ID: c.ID, + Name: c.Name, + Type: providerName + "/" + resourceType + "/" + subPEC, + Properties: props, + } +} + +// --- Private link resources --- + +type armGroupIDInformation struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *groupIDInfoProps `json:"properties,omitempty"` +} + +type groupIDInfoProps struct { + GroupID string `json:"groupId,omitempty"` + RequiredMembers []string `json:"requiredMembers,omitempty"` + RequiredZoneNames []string `json:"requiredZoneNames,omitempty"` +} + +type armPLRList struct { + Value []armGroupIDInformation `json:"value"` + NextLink string `json:"nextLink,omitempty"` +} + +func toARMGroupIDInformation(g *dbxdriver.GroupIDInformation) armGroupIDInformation { + return armGroupIDInformation{ + ID: g.ID, + Name: g.Name, + Type: providerName + "/" + resourceType + "/" + subPLR, + Properties: &groupIDInfoProps{ + GroupID: g.GroupID, + RequiredMembers: g.RequiredMembers, + RequiredZoneNames: g.RequiredZoneNames, + }, + } +} + +// --- Virtual network peerings --- + +type armPeering struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Properties *peeringProps `json:"properties,omitempty"` +} + +type peeringProps struct { + AllowForwardedTraffic bool `json:"allowForwardedTraffic"` + AllowGatewayTransit bool `json:"allowGatewayTransit"` + AllowVirtualNetworkAccess bool `json:"allowVirtualNetworkAccess"` + UseRemoteGateways bool `json:"useRemoteGateways"` + DatabricksVirtualNetwork *armSubResource `json:"databricksVirtualNetwork,omitempty"` + DatabricksAddressSpace *armAddressSpace `json:"databricksAddressSpace,omitempty"` + RemoteVirtualNetwork *armSubResource `json:"remoteVirtualNetwork,omitempty"` + RemoteAddressSpace *armAddressSpace `json:"remoteAddressSpace,omitempty"` + PeeringState string `json:"peeringState,omitempty"` + ProvisioningState string `json:"provisioningState,omitempty"` +} + +type armAddressSpace struct { + AddressPrefixes []string `json:"addressPrefixes,omitempty"` +} + +type armPeeringList struct { + Value []armPeering `json:"value"` + NextLink string `json:"nextLink,omitempty"` +} + +func toARMPeering(p *dbxdriver.VirtualNetworkPeering) armPeering { + props := &peeringProps{ + AllowForwardedTraffic: p.AllowForwardedTraffic, + AllowGatewayTransit: p.AllowGatewayTransit, + AllowVirtualNetworkAccess: p.AllowVirtualNetworkAccess, + UseRemoteGateways: p.UseRemoteGateways, + DatabricksAddressSpace: toARMAddressSpace(p.DatabricksAddressSpace), + RemoteAddressSpace: toARMAddressSpace(p.RemoteAddressSpace), + PeeringState: p.PeeringState, + ProvisioningState: p.ProvisioningState, + } + if p.DatabricksVNetID != "" { + props.DatabricksVirtualNetwork = &armSubResource{ID: p.DatabricksVNetID} + } + + if p.RemoteVNetID != "" { + props.RemoteVirtualNetwork = &armSubResource{ID: p.RemoteVNetID} + } + + return armPeering{ + ID: p.ID, + Name: p.Name, + Type: providerName + "/" + resourceType + "/" + subPeering, + Properties: props, + } +} + +func toARMAddressSpace(in *dbxdriver.AddressSpace) *armAddressSpace { + if in == nil { + return nil + } + + return &armAddressSpace{AddressPrefixes: in.AddressPrefixes} +} + +func fromARMAddressSpace(in *armAddressSpace) *dbxdriver.AddressSpace { + if in == nil { + return nil + } + + return &dbxdriver.AddressSpace{AddressPrefixes: in.AddressPrefixes} +} + +// --- Outbound network dependencies --- + +type armOutboundEndpoint struct { + Category string `json:"category,omitempty"` + Endpoints []armEndpointDependency `json:"endpoints,omitempty"` +} + +type armEndpointDependency struct { + DomainName string `json:"domainName,omitempty"` + EndpointDetails []armEndpointDetail `json:"endpointDetails,omitempty"` +} + +type armEndpointDetail struct { + Port int32 `json:"port,omitempty"` +} + +func toARMOutbound(e *dbxdriver.OutboundEndpoint) armOutboundEndpoint { + out := armOutboundEndpoint{Category: e.Category} + + for i := range e.Endpoints { + dep := armEndpointDependency{DomainName: e.Endpoints[i].DomainName} + for j := range e.Endpoints[i].EndpointDetails { + dep.EndpointDetails = append(dep.EndpointDetails, + armEndpointDetail{Port: e.Endpoints[i].EndpointDetails[j].Port}) + } + + out.Endpoints = append(out.Endpoints, dep) + } + + return out +} + +// --- Operations --- + +type armOperation struct { + Name string `json:"name,omitempty"` + IsDataAction bool `json:"isDataAction"` + Display *armOperationDisp `json:"display,omitempty"` +} + +type armOperationDisp struct { + Provider string `json:"provider,omitempty"` + Resource string `json:"resource,omitempty"` + Operation string `json:"operation,omitempty"` + Description string `json:"description,omitempty"` +} + +type armOperationList struct { + Value []armOperation `json:"value"` + NextLink string `json:"nextLink,omitempty"` +} + +func toARMOperation(o *dbxdriver.Operation) armOperation { + return armOperation{ + Name: o.Name, + IsDataAction: o.IsDataAction, + Display: &armOperationDisp{ + Provider: o.Provider, + Resource: o.Resource, + Operation: o.Operation, + Description: o.Description, + }, + } +} diff --git a/server/azure/databricks/arm_vnetpeering_roundtrip_test.go b/server/azure/databricks/arm_vnetpeering_roundtrip_test.go new file mode 100644 index 00000000..c7cbd2e5 --- /dev/null +++ b/server/azure/databricks/arm_vnetpeering_roundtrip_test.go @@ -0,0 +1,207 @@ +package databricks_test + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks" +) + +const vnetPeeringResourceType = "Microsoft.Databricks/workspaces/virtualNetworkPeerings" + +// newVNetPeeringClient builds a real armdatabricks VNetPeering client pointed at +// the in-memory emulator. +func newVNetPeeringClient(t *testing.T, opts *arm.ClientOptions, sub string) *armdatabricks.VNetPeeringClient { + t.Helper() + + client, err := armdatabricks.NewVNetPeeringClient(sub, fakeCred{}, opts) + if err != nil { + t.Fatalf("new vnet peering client: %v", err) + } + + return client +} + +// createPeering performs a BeginCreateOrUpdate + PollUntilDone against the +// emulator, echoing back the standard peering config used by the tests. +func createPeering( + t *testing.T, client *armdatabricks.VNetPeeringClient, name, remoteVNetID string, +) armdatabricks.VirtualNetworkPeering { + t.Helper() + + ctx := context.Background() + + poller, err := client.BeginCreateOrUpdate(ctx, testRG, testWS, name, armdatabricks.VirtualNetworkPeering{ + Properties: &armdatabricks.VirtualNetworkPeeringPropertiesFormat{ + AllowVirtualNetworkAccess: to.Ptr(true), + AllowForwardedTraffic: to.Ptr(true), + RemoteVirtualNetwork: &armdatabricks.VirtualNetworkPeeringPropertiesFormatRemoteVirtualNetwork{ + ID: to.Ptr(remoteVNetID), + }, + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + res, err := poller.PollUntilDone(ctx, nil) + if err != nil { + t.Fatalf("create PollUntilDone: %v", err) + } + + return res.VirtualNetworkPeering +} + +func TestSDKVNetPeeringLifecycle(t *testing.T) { + opts, sub := newARMOptions(t) + seedWorkspace(t, opts, testRG, testWS) + + client := newVNetPeeringClient(t, opts, sub) + ctx := context.Background() + + const ( + peeringName = "peer-1" + remoteVNetID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/remote" + ) + + created := createPeering(t, client, peeringName, remoteVNetID) + + if created.Name == nil || *created.Name != peeringName { + t.Fatalf("got name %v, want %q", created.Name, peeringName) + } + + if created.Type == nil || *created.Type != vnetPeeringResourceType { + t.Fatalf("got type %v, want %q", created.Type, vnetPeeringResourceType) + } + + if created.Properties == nil { + t.Fatal("expected peering properties on create") + } + + if created.Properties.PeeringState == nil || + *created.Properties.PeeringState != armdatabricks.PeeringStateConnected { + t.Fatalf("got peering state %v, want Connected", created.Properties.PeeringState) + } + + if created.Properties.ProvisioningState == nil || + *created.Properties.ProvisioningState != armdatabricks.PeeringProvisioningStateSucceeded { + t.Fatalf("got provisioning state %v, want Succeeded", created.Properties.ProvisioningState) + } + + if created.Properties.AllowVirtualNetworkAccess == nil || !*created.Properties.AllowVirtualNetworkAccess { + t.Fatalf("expected AllowVirtualNetworkAccess true, got %v", created.Properties.AllowVirtualNetworkAccess) + } + + if created.Properties.RemoteVirtualNetwork == nil || + created.Properties.RemoteVirtualNetwork.ID == nil || + *created.Properties.RemoteVirtualNetwork.ID != remoteVNetID { + t.Fatalf("got remote vnet %v, want %q", created.Properties.RemoteVirtualNetwork, remoteVNetID) + } + + got, err := client.Get(ctx, testRG, testWS, peeringName, nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Name == nil || *got.Name != peeringName { + t.Fatalf("Get got name %v, want %q", got.Name, peeringName) + } + + if got.Properties == nil || + got.Properties.AllowForwardedTraffic == nil || + !*got.Properties.AllowForwardedTraffic { + t.Fatalf("expected AllowForwardedTraffic true echoed back, got %+v", got.Properties) + } + + pager := client.NewListByWorkspacePager(testRG, testWS, nil) + + page, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("ListByWorkspace: %v", err) + } + + if len(page.Value) != 1 { + t.Fatalf("got %d peerings, want 1", len(page.Value)) + } + + delPoller, err := client.BeginDelete(ctx, testRG, testWS, peeringName, nil) + if err != nil { + t.Fatalf("BeginDelete: %v", err) + } + + if _, err = delPoller.PollUntilDone(ctx, nil); err != nil { + t.Fatalf("delete PollUntilDone: %v", err) + } + + if _, err = client.Get(ctx, testRG, testWS, peeringName, nil); err == nil { + t.Fatal("expected error after delete") + } +} + +func TestSDKVNetPeeringCreateMissingWorkspace(t *testing.T) { + opts, sub := newARMOptions(t) + + // No workspace is seeded: the parent does not exist. + client := newVNetPeeringClient(t, opts, sub) + + poller, err := client.BeginCreateOrUpdate(context.Background(), testRG, testWS, "peer-1", + armdatabricks.VirtualNetworkPeering{ + Properties: &armdatabricks.VirtualNetworkPeeringPropertiesFormat{ + AllowVirtualNetworkAccess: to.Ptr(true), + RemoteVirtualNetwork: &armdatabricks.VirtualNetworkPeeringPropertiesFormatRemoteVirtualNetwork{ + ID: to.Ptr("/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/remote"), + }, + }, + }, nil) + if err != nil { + // A synchronous failure is an acceptable outcome too. + return + } + + if _, err = poller.PollUntilDone(context.Background(), nil); err == nil { + t.Fatal("expected error creating peering against a missing workspace") + } +} + +func TestSDKVNetPeeringGetMissing(t *testing.T) { + opts, sub := newARMOptions(t) + seedWorkspace(t, opts, testRG, testWS) + + client := newVNetPeeringClient(t, opts, sub) + + if _, err := client.Get(context.Background(), testRG, testWS, "does-not-exist", nil); err == nil { + t.Fatal("expected error for missing peering") + } +} + +func TestSDKVNetPeeringListMultiple(t *testing.T) { + opts, sub := newARMOptions(t) + seedWorkspace(t, opts, testRG, testWS) + + client := newVNetPeeringClient(t, opts, sub) + ctx := context.Background() + + createPeering(t, client, "peer-a", + "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/remote-a") + createPeering(t, client, "peer-b", + "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/remote-b") + + pager := client.NewListByWorkspacePager(testRG, testWS, nil) + + var count int + + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + t.Fatalf("ListByWorkspace: %v", err) + } + + count += len(page.Value) + } + + if count != 2 { + t.Fatalf("got %d peerings, want 2", count) + } +} diff --git a/server/azure/databricks/handler.go b/server/azure/databricks/handler.go index e0cb53a7..efc7ad1b 100644 --- a/server/azure/databricks/handler.go +++ b/server/azure/databricks/handler.go @@ -4,14 +4,15 @@ // clients configured with a custom endpoint hit this handler the same way they // hit management.azure.com. // -// MVP coverage (Microsoft.Databricks/workspaces): +// Coverage (Microsoft.Databricks): // -// PUT .../resourceGroups/{rg}/providers/Microsoft.Databricks/workspaces/{w} — Create or update -// GET .../resourceGroups/{rg}/providers/Microsoft.Databricks/workspaces/{w} — Get -// PATCH .../resourceGroups/{rg}/providers/Microsoft.Databricks/workspaces/{w} — Update tags -// DELETE .../resourceGroups/{rg}/providers/Microsoft.Databricks/workspaces/{w} — Delete -// GET .../resourceGroups/{rg}/providers/Microsoft.Databricks/workspaces — List by resource group -// GET /subscriptions/{sub}/providers/Microsoft.Databricks/workspaces — List by subscription +// workspaces CRUD, list by RG / subscription (#164) +// accessConnectors CRUD, update, list by RG / subscription +// workspaces/{w}/privateEndpointConnections create, get, list, delete +// workspaces/{w}/privateLinkResources get, list +// workspaces/{w}/virtualNetworkPeerings createOrUpdate, get, list, delete +// workspaces/{w}/outboundNetworkDependenciesEndpoints list +// /providers/Microsoft.Databricks/operations list // // Mutating ops return 200 OK with the resource body inline so the SDK's LRO // poller terminates on the first response. @@ -19,6 +20,7 @@ package databricks import ( "net/http" + "strings" "github.com/stackshy/cloudemu/v2/server/wire/azurearm" dbxdriver "github.com/stackshy/cloudemu/v2/services/databricks/driver" @@ -27,6 +29,19 @@ import ( const ( providerName = "Microsoft.Databricks" resourceType = "workspaces" + + accessConnectorsType = "accessConnectors" + + // Workspace sub-resource collections. + subPEC = "privateEndpointConnections" + subPLR = "privateLinkResources" + subPeering = "virtualNetworkPeerings" + subOutbound = "outboundNetworkDependenciesEndpoints" + + // operationsPath is the subscription-less provider operations list path, + // which azurearm.ParsePath does not model (it requires a /subscriptions + // prefix), so the handler matches it directly. + operationsPath = "providers/" + providerName + "/operations" ) // Handler serves Microsoft.Databricks ARM requests against a Databricks driver. @@ -39,18 +54,37 @@ func New(drv dbxdriver.Databricks) *Handler { return &Handler{dbx: drv} } -// Matches returns true for ARM Microsoft.Databricks/workspaces paths. +// Matches returns true for the Microsoft.Databricks ARM surface: workspaces (and +// their sub-resources), accessConnectors, and the provider operations list. func (*Handler) Matches(r *http.Request) bool { + if isOperationsPath(r.URL.Path) { + return true + } + rp, ok := azurearm.ParsePath(r.URL.Path) if !ok { return false } - return rp.Provider == providerName && rp.ResourceType == resourceType + return strings.EqualFold(rp.Provider, providerName) && + (strings.EqualFold(rp.ResourceType, resourceType) || + strings.EqualFold(rp.ResourceType, accessConnectorsType)) +} + +// isOperationsPath reports whether urlPath is the provider operations list path +// (case-insensitive on the provider segment, tolerant of a trailing slash). +func isOperationsPath(urlPath string) bool { + return strings.EqualFold(strings.Trim(urlPath, "/"), operationsPath) } -// ServeHTTP routes the request based on path shape and method. +// ServeHTTP routes the request based on resource type, path shape, and method. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if isOperationsPath(r.URL.Path) { + h.serveOperations(w, r) + + return + } + rp, ok := azurearm.ParsePath(r.URL.Path) if !ok { azurearm.WriteError(w, http.StatusBadRequest, "InvalidPath", "malformed ARM path") @@ -58,6 +92,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + switch { + case strings.EqualFold(rp.ResourceType, accessConnectorsType): + h.serveAccessConnectors(w, r, &rp) + case strings.EqualFold(rp.ResourceType, resourceType): + h.serveWorkspaces(w, r, &rp) + default: + azurearm.WriteError(w, http.StatusNotFound, "ResourceNotFound", "unsupported resource type") + } +} + +// serveWorkspaces routes workspace collection, resource, and sub-resource paths. +func (h *Handler) serveWorkspaces(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { // Collection: list by resource group (rg present) or by subscription. if rp.ResourceName == "" { if r.Method != http.MethodGet { @@ -66,25 +112,47 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - h.listWorkspaces(w, r, &rp) + h.listWorkspaces(w, r, rp) + + return + } + + if rp.SubResource != "" { + h.serveWorkspaceChild(w, r, rp) return } switch r.Method { case http.MethodPut: - h.createOrUpdateWorkspace(w, r, &rp) + h.createOrUpdateWorkspace(w, r, rp) case http.MethodGet: - h.getWorkspace(w, r, &rp) + h.getWorkspace(w, r, rp) case http.MethodPatch: - h.updateWorkspace(w, r, &rp) + h.updateWorkspace(w, r, rp) case http.MethodDelete: - h.deleteWorkspace(w, r, &rp) + h.deleteWorkspace(w, r, rp) default: writeMethodNotAllowed(w) } } +// serveWorkspaceChild routes a workspace sub-resource collection. +func (h *Handler) serveWorkspaceChild(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + switch { + case strings.EqualFold(rp.SubResource, subPEC): + h.servePEC(w, r, rp) + case strings.EqualFold(rp.SubResource, subPLR): + h.servePLR(w, r, rp) + case strings.EqualFold(rp.SubResource, subPeering): + h.servePeering(w, r, rp) + case strings.EqualFold(rp.SubResource, subOutbound): + h.serveOutbound(w, r, rp) + default: + azurearm.WriteError(w, http.StatusNotFound, "ResourceNotFound", "unsupported sub-resource") + } +} + func writeMethodNotAllowed(w http.ResponseWriter) { azurearm.WriteError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") } diff --git a/server/azure/databricks/sdk_roundtrip_test.go b/server/azure/databricks/sdk_roundtrip_test.go index 6b4e69e5..14a89205 100644 --- a/server/azure/databricks/sdk_roundtrip_test.go +++ b/server/azure/databricks/sdk_roundtrip_test.go @@ -2,19 +2,13 @@ package databricks_test import ( "context" - "net/http/httptest" "testing" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/databricks/armdatabricks" - - "github.com/stackshy/cloudemu/v2" - azureserver "github.com/stackshy/cloudemu/v2/server/azure" ) const ( @@ -32,28 +26,9 @@ func (fakeCred) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcor func newWorkspacesClient(t *testing.T) *armdatabricks.WorkspacesClient { t.Helper() - cloudP := cloudemu.NewAzure() - srv := azureserver.New(azureserver.Drivers{Databricks: cloudP.Databricks}) - - ts := httptest.NewTLSServer(srv) - t.Cleanup(ts.Close) - - myCloud := cloud.Configuration{ - ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/", - Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ - cloud.ResourceManager: {Endpoint: ts.URL, Audience: "https://management.azure.com"}, - }, - } - - opts := &arm.ClientOptions{ - ClientOptions: azcore.ClientOptions{ - Cloud: myCloud, - Transport: ts.Client(), - Retry: policy.RetryOptions{MaxRetries: -1}, - }, - } + opts, sub := newARMOptions(t) - client, err := armdatabricks.NewWorkspacesClient("sub-1", fakeCred{}, opts) + client, err := armdatabricks.NewWorkspacesClient(sub, fakeCred{}, opts) if err != nil { t.Fatalf("new client: %v", err) } diff --git a/server/azure/disks/disks_costfields_test.go b/server/azure/disks/disks_costfields_test.go new file mode 100644 index 00000000..60ac9dcc --- /dev/null +++ b/server/azure/disks/disks_costfields_test.go @@ -0,0 +1,77 @@ +package disks_test + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v5" + + "github.com/stackshy/cloudemu/v2" + azureserver "github.com/stackshy/cloudemu/v2/server/azure" +) + +// TestSDKDiskCostFields verifies that provisioned-performance cost fields +// (diskIOPSReadWrite, diskMBpsReadWrite, tier, sku.tier) set on a real +// armcompute.DisksClient create round-trip back through Get. +func TestSDKDiskCostFields(t *testing.T) { + cloudP := cloudemu.NewAzure() + srv := azureserver.New(azureserver.Drivers{ + VirtualMachines: cloudP.VirtualMachines, + Disks: cloudP.VirtualMachines, + }) + + ts := httptest.NewTLSServer(srv) + t.Cleanup(ts.Close) + + client := newDisksClient(t, ts) + ctx := context.Background() + + createPoller, err := client.BeginCreateOrUpdate(ctx, "rg-1", "perf-disk-1", + armcompute.Disk{ + Location: to.Ptr("eastus"), + SKU: &armcompute.DiskSKU{Name: to.Ptr(armcompute.DiskStorageAccountTypesPremiumV2LRS)}, + Properties: &armcompute.DiskProperties{ + CreationData: &armcompute.CreationData{CreateOption: to.Ptr(armcompute.DiskCreateOptionEmpty)}, + DiskSizeGB: to.Ptr[int32](256), + DiskIOPSReadWrite: to.Ptr[int64](5000), + DiskMBpsReadWrite: to.Ptr[int64](200), + Tier: to.Ptr("P10"), + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + if _, err := createPoller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: time.Millisecond}); err != nil { + t.Fatalf("create poll: %v", err) + } + + got, err := client.Get(ctx, "rg-1", "perf-disk-1", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Properties == nil { + t.Fatal("got.Properties is nil") + } + + if got.Properties.DiskIOPSReadWrite == nil || *got.Properties.DiskIOPSReadWrite != 5000 { + t.Errorf("diskIOPSReadWrite=%v want 5000", got.Properties.DiskIOPSReadWrite) + } + + if got.Properties.DiskMBpsReadWrite == nil || *got.Properties.DiskMBpsReadWrite != 200 { + t.Errorf("diskMBpsReadWrite=%v want 200", got.Properties.DiskMBpsReadWrite) + } + + if got.Properties.Tier == nil || *got.Properties.Tier != "P10" { + t.Errorf("properties.tier=%v want P10", got.Properties.Tier) + } + + if got.SKU == nil || got.SKU.Tier == nil || *got.SKU.Tier != "P10" { + t.Errorf("sku.tier=%v want P10", got.SKU) + } +} diff --git a/server/azure/disks/handler.go b/server/azure/disks/handler.go index 97b7a08d..88dc6489 100644 --- a/server/azure/disks/handler.go +++ b/server/azure/disks/handler.go @@ -115,6 +115,9 @@ func (h *Handler) createOrUpdate(w http.ResponseWriter, r *http.Request, rp azur cfg := computedriver.VolumeConfig{ Size: req.Properties.DiskSizeGB, VolumeType: skuName(req.SKU), + IOPS: req.Properties.DiskIOPSReadWrite, + Throughput: req.Properties.DiskMBpsReadWrite, + Tier: diskTier(req.Properties.Tier, skuTier(req.SKU)), Tags: mergeDiskTags(req.Tags, rp.ResourceName), } @@ -213,21 +216,39 @@ func toDiskResponse(vol *computedriver.VolumeInfo, rp azurearm.ResourcePath, loc name := tagOr(vol.Tags, armNameTag, rp.ResourceName) + var sku *diskSKU + if vol.VolumeType != "" || vol.Tier != "" { + sku = &diskSKU{Name: vol.VolumeType, Tier: vol.Tier} + } + return diskResponse{ ID: azurearm.BuildResourceID(rp.Subscription, rp.ResourceGroup, providerName, resourceType, name), Name: name, Type: providerName + "/" + resourceType, Location: location, + SKU: sku, Tags: stripInternalDiskTags(vol.Tags), Properties: diskResponseProps{ ProvisioningState: "Succeeded", DiskSizeGB: vol.Size, DiskState: diskStateFor(vol.State), CreationData: &creationData{CreateOption: "Empty"}, + DiskIOPSReadWrite: vol.IOPS, + DiskMBpsReadWrite: vol.Throughput, + Tier: vol.Tier, }, } } +// diskTier prefers properties.tier, falling back to sku.tier. +func diskTier(propTier, skuTier string) string { + if propTier != "" { + return propTier + } + + return skuTier +} + // ARM disk states we expose. Real Azure has more (ActiveSAS, ReadyToUpload, // etc.) but the driver only models attached/unattached. const ( @@ -251,6 +272,14 @@ func skuName(s *diskSKU) string { return s.Name } +func skuTier(s *diskSKU) string { + if s == nil { + return "" + } + + return s.Tier +} + func mergeDiskTags(in map[string]string, name string) map[string]string { out := make(map[string]string, len(in)+1) diff --git a/server/azure/disks/types.go b/server/azure/disks/types.go index 98cf1b62..6f7c8754 100644 --- a/server/azure/disks/types.go +++ b/server/azure/disks/types.go @@ -12,8 +12,11 @@ type diskRequest struct { } type diskRequestProps struct { - CreationData *creationData `json:"creationData,omitempty"` - DiskSizeGB int `json:"diskSizeGB,omitempty"` + CreationData *creationData `json:"creationData,omitempty"` + DiskSizeGB int `json:"diskSizeGB,omitempty"` + DiskIOPSReadWrite int `json:"diskIOPSReadWrite"` + DiskMBpsReadWrite int `json:"diskMBpsReadWrite"` + Tier string `json:"tier"` } type creationData struct { @@ -42,6 +45,9 @@ type diskResponseProps struct { DiskSizeGB int `json:"diskSizeGB"` DiskState string `json:"diskState"` CreationData *creationData `json:"creationData,omitempty"` + DiskIOPSReadWrite int `json:"diskIOPSReadWrite,omitempty"` + DiskMBpsReadWrite int `json:"diskMBpsReadWrite,omitempty"` + Tier string `json:"tier,omitempty"` } type diskListResponse struct { diff --git a/server/azure/fromprovider.go b/server/azure/fromprovider.go index 757bf76a..64ba07d2 100644 --- a/server/azure/fromprovider.go +++ b/server/azure/fromprovider.go @@ -28,6 +28,7 @@ func DriversFrom(p *azureprovider.Provider) Drivers { TableStorage: p.TableStorage, CosmosDB: p.CosmosDB, ManagedCassandra: p.ManagedCassandra, + CosmosPostgreSQL: p.CosmosPostgreSQL, Network: p.VNet, Monitor: p.Monitor, Functions: p.Functions, diff --git a/server/azure/functions/handler.go b/server/azure/functions/handler.go index f7b2a1d0..6675eb43 100644 --- a/server/azure/functions/handler.go +++ b/server/azure/functions/handler.go @@ -5,11 +5,13 @@ // // MVP coverage: // -// PUT .../sites/{name} — CreateOrUpdate -// GET .../sites/{name} — Get -// GET .../sites — List in resource group / subscription -// DELETE .../sites/{name} — Delete -// POST /api/{name} — Synchronous invoke (non-ARM, mirrors how +// PUT .../sites/{name} — CreateOrUpdate +// GET .../sites/{name} — Get +// GET .../sites — List in resource group / subscription +// DELETE .../sites/{name} — Delete +// PUT .../serverfarms/{name} — CreateOrUpdate App Service plan +// GET .../serverfarms/{name} — Get App Service plan +// POST /api/{name} — Synchronous invoke (non-ARM, mirrors how // real Function Apps are hit at // .azurewebsites.net/api/) // @@ -17,6 +19,7 @@ package functions import ( + "context" "encoding/json" "io" "net/http" @@ -24,13 +27,15 @@ import ( "time" cerrors "github.com/stackshy/cloudemu/v2/errors" + azfunctions "github.com/stackshy/cloudemu/v2/providers/azure/functions" "github.com/stackshy/cloudemu/v2/server/wire/azurearm" sdrv "github.com/stackshy/cloudemu/v2/services/serverless/driver" ) const ( - providerName = "Microsoft.Web" - resourceType = "sites" + providerName = "Microsoft.Web" + resourceType = "sites" + serverFarmsType = "serverfarms" functionAppKind = "functionapp" defaultLocation = "eastus" @@ -39,6 +44,14 @@ const ( maxControlBytes = 1 << 20 ) +// appServicePlanStore is the App Service plan surface the handler needs on top +// of the serverless driver. The Azure provider Mock (*azfunctions.Mock) +// satisfies it; backends that don't model plans fall through to 501. +type appServicePlanStore interface { + CreateAppServicePlan(ctx context.Context, p azfunctions.AppServicePlan) (*azfunctions.AppServicePlan, error) + ListAppServicePlans(ctx context.Context) ([]azfunctions.AppServicePlan, error) +} + // Handler serves ARM JSON requests for Microsoft.Web/sites and direct invoke // requests at /api/{name}. type Handler struct { @@ -62,7 +75,11 @@ func (*Handler) Matches(r *http.Request) bool { return false } - return rp.Provider == providerName && rp.ResourceType == resourceType + if rp.Provider != providerName { + return false + } + + return rp.ResourceType == resourceType || strings.EqualFold(rp.ResourceType, serverFarmsType) } // ServeHTTP routes requests by URL shape. @@ -78,6 +95,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if strings.EqualFold(rp.ResourceType, serverFarmsType) { + h.servePlan(w, r, rp) + return + } + switch { case rp.ResourceName != "": h.serveResource(w, r, rp) @@ -181,6 +203,113 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request, rp azurearm.Res w.WriteHeader(http.StatusOK) } +// servePlan routes Microsoft.Web/serverfarms (App Service plan) requests. Only +// backends that model plans (the Azure provider Mock) are served; others 501. +// +//nolint:gocritic // rp travels the dispatch chain once per request. +func (h *Handler) servePlan(w http.ResponseWriter, r *http.Request, rp azurearm.ResourcePath) { + store, ok := h.fn.(appServicePlanStore) + if !ok { + azurearm.WriteError(w, http.StatusNotImplemented, "NotImplemented", + "app service plans not supported by this backend") + return + } + + if rp.ResourceName == "" { + azurearm.WriteError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", + "serverfarms collection operations are not supported") + return + } + + switch r.Method { + case http.MethodPut: + createPlan(w, r, rp, store) + case http.MethodGet: + getPlan(w, r, rp, store) + default: + azurearm.WriteError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") + } +} + +//nolint:gocritic // rp travels the dispatch chain once per request. +func createPlan(w http.ResponseWriter, r *http.Request, rp azurearm.ResourcePath, store appServicePlanStore) { + if rp.ResourceGroup == "" { + azurearm.WriteError(w, http.StatusBadRequest, "InvalidPath", "missing resourceGroups segment") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxControlBytes) + + var req createServerFarmRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { + azurearm.WriteError(w, http.StatusBadRequest, "InvalidRequestContent", err.Error()) + return + } + + plan, err := store.CreateAppServicePlan(r.Context(), azfunctions.AppServicePlan{ + Name: rp.ResourceName, + SKUName: req.SKU.Name, + SKUTier: req.SKU.Tier, + Kind: req.Kind, + Capacity: req.SKU.Capacity, + Tags: req.Tags, + }) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toServerFarmResource(rp, plan)) +} + +//nolint:gocritic // rp travels the dispatch chain once per request. +func getPlan(w http.ResponseWriter, r *http.Request, rp azurearm.ResourcePath, store appServicePlanStore) { + plans, err := store.ListAppServicePlans(r.Context()) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + for i := range plans { + if plans[i].Name == rp.ResourceName { + azurearm.WriteJSON(w, http.StatusOK, toServerFarmResource(rp, &plans[i])) + return + } + } + + azurearm.WriteError(w, http.StatusNotFound, "ResourceNotFound", + "app service plan "+rp.ResourceName+" not found") +} + +//nolint:gocritic // rp is request-scoped. +func toServerFarmResource(rp azurearm.ResourcePath, plan *azfunctions.AppServicePlan) serverFarmResource { + location := plan.Location + if location == "" { + location = defaultLocation + } + + id := azurearm.BuildResourceID(rp.Subscription, rp.ResourceGroup, + providerName, serverFarmsType, rp.ResourceName) + + return serverFarmResource{ + ID: id, + Name: plan.Name, + Type: providerName + "/" + serverFarmsType, + Kind: plan.Kind, + Location: location, + Tags: plan.Tags, + SKU: &serverFarmSKU{ + Name: plan.SKUName, + Tier: plan.SKUTier, + Capacity: plan.Capacity, + }, + Properties: serverFarmProperties{ + ProvisioningState: "Succeeded", + Status: "Ready", + }, + } +} + func (h *Handler) serveInvoke(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { azurearm.WriteError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "invoke requires POST") diff --git a/server/azure/functions/sdk_plan_roundtrip_test.go b/server/azure/functions/sdk_plan_roundtrip_test.go new file mode 100644 index 00000000..4c130971 --- /dev/null +++ b/server/azure/functions/sdk_plan_roundtrip_test.go @@ -0,0 +1,114 @@ +package functions_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v3" + "github.com/stackshy/cloudemu/v2" + azureserver "github.com/stackshy/cloudemu/v2/server/azure" +) + +func newPlansClient(t *testing.T, ts *httptest.Server) *armappservice.PlansClient { + t.Helper() + + myCloud := cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/", + Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ + cloud.ResourceManager: { + Endpoint: ts.URL, + Audience: "https://management.azure.com", + }, + }, + } + + opts := &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Cloud: myCloud, + Transport: ts.Client(), + Retry: policy.RetryOptions{MaxRetries: -1}, + }, + } + + clientFactory, err := armappservice.NewClientFactory(subID, fakeCred{}, opts) + if err != nil { + t.Fatalf("NewClientFactory: %v", err) + } + + return clientFactory.NewPlansClient() +} + +func TestSDKAzureAppServicePlanCreateGet(t *testing.T) { + cloudP := cloudemu.NewAzure() + srv := azureserver.New(azureserver.Drivers{Functions: cloudP.Functions}) + + ts := httptest.NewTLSServer(srv) + t.Cleanup(ts.Close) + + client := newPlansClient(t, ts) + ctx := context.Background() + + poller, err := client.BeginCreateOrUpdate(ctx, rgName, "sdk-plan", + armappservice.Plan{ + Kind: to.Ptr("linux"), + Location: to.Ptr("eastus"), + SKU: &armappservice.SKUDescription{ + Name: to.Ptr("P1v3"), + Tier: to.Ptr("PremiumV3"), + Capacity: to.Ptr[int32](3), + }, + }, + nil, + ) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + created, err := poller.PollUntilDone(ctx, &runtimePollerOptions) + if err != nil { + t.Fatalf("PollUntilDone: %v", err) + } + + assertPlan(t, created.Plan, "create") + + got, err := client.Get(ctx, rgName, "sdk-plan", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + assertPlan(t, got.Plan, "get") +} + +func assertPlan(t *testing.T, p armappservice.Plan, stage string) { + t.Helper() + + if p.Name == nil || *p.Name != "sdk-plan" { + t.Fatalf("%s Name = %v, want sdk-plan", stage, p.Name) + } + + if p.Kind == nil || *p.Kind != "linux" { + t.Fatalf("%s Kind = %v, want linux", stage, p.Kind) + } + + if p.SKU == nil { + t.Fatalf("%s SKU is nil", stage) + } + + if p.SKU.Name == nil || *p.SKU.Name != "P1v3" { + t.Fatalf("%s SKU.Name = %v, want P1v3", stage, p.SKU.Name) + } + + if p.SKU.Tier == nil || *p.SKU.Tier != "PremiumV3" { + t.Fatalf("%s SKU.Tier = %v, want PremiumV3", stage, p.SKU.Tier) + } + + if p.SKU.Capacity == nil || *p.SKU.Capacity != 3 { + t.Fatalf("%s SKU.Capacity = %v, want 3", stage, p.SKU.Capacity) + } +} diff --git a/server/azure/functions/types.go b/server/azure/functions/types.go index e69a1417..a07b5b69 100644 --- a/server/azure/functions/types.go +++ b/server/azure/functions/types.go @@ -62,3 +62,36 @@ type createSiteConfig struct { LinuxFxVersion string `json:"linuxFxVersion"` AppSettings []nameValue `json:"appSettings"` } + +// serverFarmResource is the ARM JSON shape for Microsoft.Web/serverfarms (App +// Service plans) returned to the SDK. The SKU carries the pricing tier a plan +// bills on — the fields an armappservice PlansClient reads back. +type serverFarmResource struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Kind string `json:"kind,omitempty"` + Location string `json:"location"` + SKU *serverFarmSKU `json:"sku,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + Properties serverFarmProperties `json:"properties"` +} + +type serverFarmSKU struct { + Name string `json:"name,omitempty"` + Tier string `json:"tier,omitempty"` + Capacity int `json:"capacity,omitempty"` +} + +type serverFarmProperties struct { + ProvisioningState string `json:"provisioningState,omitempty"` + Status string `json:"status,omitempty"` +} + +// createServerFarmRequest captures the fields read from a serverfarms PUT body. +type createServerFarmRequest struct { + Kind string `json:"kind"` + Location string `json:"location"` + Tags map[string]string `json:"tags"` + SKU serverFarmSKU `json:"sku"` +} diff --git a/server/azure/network/handler.go b/server/azure/network/handler.go index 703b0fd4..6e2692e0 100644 --- a/server/azure/network/handler.go +++ b/server/azure/network/handler.go @@ -25,15 +25,17 @@ import ( ) const ( - providerName = "Microsoft.Network" - typeVNet = "virtualNetworks" - typeNSG = "networkSecurityGroups" - typeLocations = "locations" - armNameTag = "cloudemu:azureNetName" - armSubnetTag = "cloudemu:azureSubnet" - armNSGTag = "cloudemu:azureNSGName" - defaultLoc = "eastus" - subResSubnets = "subnets" + providerName = "Microsoft.Network" + typeVNet = "virtualNetworks" + typeNSG = "networkSecurityGroups" + typePublicIP = "publicIPAddresses" + typeLocations = "locations" + armNameTag = "cloudemu:azureNetName" + armSubnetTag = "cloudemu:azureSubnet" + armNSGTag = "cloudemu:azureNSGName" + armPublicIPTag = "cloudemu:azurePublicIP" + defaultLoc = "eastus" + subResSubnets = "subnets" ) // Handler serves Microsoft.Network ARM requests against a networking driver. @@ -60,7 +62,7 @@ func (*Handler) Matches(r *http.Request) bool { } switch rp.ResourceType { - case typeVNet, typeNSG, typeLocations: + case typeVNet, typeNSG, typePublicIP, typeLocations: return true } @@ -89,6 +91,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.routeVNet(w, r, rp) case typeNSG: h.routeNSG(w, r, rp) + case typePublicIP: + h.routePublicIP(w, r, rp) default: azurearm.WriteError(w, http.StatusNotImplemented, "NotImplemented", "unsupported resource type: "+rp.ResourceType) @@ -428,6 +432,95 @@ func (h *Handler) deleteNSG(w http.ResponseWriter, r *http.Request, rp azurearm. writeAcceptedAsync(w, r, rp.Subscription, "nsg-delete-"+rp.ResourceName, nil) } +// PublicIP operations. + +//nolint:gocritic // rp is a request-scoped value +func (h *Handler) routePublicIP(w http.ResponseWriter, r *http.Request, rp azurearm.ResourcePath) { + if rp.ResourceName == "" { + h.listPublicIPs(w, r, rp) + return + } + + switch r.Method { + case http.MethodPut: + h.createPublicIP(w, r, rp) + case http.MethodGet: + h.getPublicIP(w, r, rp) + default: + azurearm.WriteError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") + } +} + +//nolint:gocritic // rp is a request-scoped value +func (h *Handler) createPublicIP(w http.ResponseWriter, r *http.Request, rp azurearm.ResourcePath) { + if rp.ResourceGroup == "" { + azurearm.WriteError(w, http.StatusBadRequest, "InvalidPath", "missing resourceGroups segment") + return + } + + var req publicIPRequest + + if !azurearm.DecodeJSON(w, r, &req) { + return + } + + sku := "" + if req.SKU != nil { + sku = req.SKU.Name + } + + cfg := netdriver.ElasticIPConfig{ + SKU: sku, + AllocationMethod: req.Properties.PublicIPAllocationMethod, + Tags: mergeTags(req.Tags, armPublicIPTag, rp.ResourceName), + } + + info, err := h.net.AllocateAddress(r.Context(), cfg) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + loc := req.Location + if loc == "" { + loc = defaultLoc + } + + body := toPublicIPResponse(info, rp, loc) + + writeAcceptedAsync(w, r, rp.Subscription, "publicip-create-"+rp.ResourceName, body) +} + +//nolint:gocritic // rp is a request-scoped value +func (h *Handler) getPublicIP(w http.ResponseWriter, r *http.Request, rp azurearm.ResourcePath) { + info, err := findPublicIPByName(r.Context(), h.net, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toPublicIPResponse(info, rp, defaultLoc)) +} + +//nolint:gocritic // rp is a request-scoped value +func (h *Handler) listPublicIPs(w http.ResponseWriter, r *http.Request, rp azurearm.ResourcePath) { + infos, err := h.net.DescribeAddresses(r.Context(), nil) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := publicIPListResponse{} + + for i := range infos { + scope := rp + scope.ResourceName = tagOr(infos[i].Tags, armPublicIPTag, infos[i].AllocationID) + out.Value = append(out.Value, toPublicIPResponse(&infos[i], scope, defaultLoc)) + } + + azurearm.WriteJSON(w, http.StatusOK, out) +} + // Lookup helpers — driver indexes by its own ID, so we match by tag. func findVNetByName(ctx context.Context, n netdriver.Networking, name string) (*netdriver.VPCInfo, error) { @@ -475,6 +568,21 @@ func findNSGByName(ctx context.Context, n netdriver.Networking, name string) (*n return nil, cerrors.Newf(cerrors.NotFound, "networkSecurityGroup %s not found", name) } +func findPublicIPByName(ctx context.Context, n netdriver.Networking, name string) (*netdriver.ElasticIP, error) { + infos, err := n.DescribeAddresses(ctx, nil) + if err != nil { + return nil, err + } + + for i := range infos { + if tagOr(infos[i].Tags, armPublicIPTag, "") == name { + return &infos[i], nil + } + } + + return nil, cerrors.Newf(cerrors.NotFound, "publicIPAddress %s not found", name) +} + // Response shaping helpers. //nolint:gocritic // rp is a request-scoped value @@ -553,6 +661,32 @@ func toNSGResponse(info *netdriver.SecurityGroupInfo, rp azurearm.ResourcePath, } } +//nolint:gocritic // rp is a request-scoped value +func toPublicIPResponse(info *netdriver.ElasticIP, rp azurearm.ResourcePath, location string) publicIPResponse { + if location == "" { + location = defaultLoc + } + + out := publicIPResponse{ + ID: azurearm.BuildResourceID(rp.Subscription, rp.ResourceGroup, providerName, typePublicIP, rp.ResourceName), + Name: rp.ResourceName, + Type: providerName + "/" + typePublicIP, + Location: location, + Tags: stripInternal(info.Tags), + Properties: publicIPRespProps{ + ProvisioningState: "Succeeded", + PublicIPAllocationMethod: info.AllocationMethod, + IPAddress: info.PublicIP, + }, + } + + if info.SKU != "" { + out.SKU = &publicIPSKU{Name: info.SKU} + } + + return out +} + // writeAcceptedAsync replies for create/delete operations. armnetwork's // poller expects either: // - a sync 200 OK with the resource body whose ProvisioningState is diff --git a/server/azure/network/network_test.go b/server/azure/network/network_test.go index bd13b17a..bad46368 100644 --- a/server/azure/network/network_test.go +++ b/server/azure/network/network_test.go @@ -115,6 +115,58 @@ func TestSDKVNetRoundTrip(t *testing.T) { } } +func TestSDKPublicIPRoundTrip(t *testing.T) { + cloudP := cloudemu.NewAzure() + srv := azureserver.New(azureserver.Drivers{Network: cloudP.VNet}) + + ts := httptest.NewTLSServer(srv) + t.Cleanup(ts.Close) + + ctx := context.Background() + opts := clientOpts(ts) + + pipClient, err := armnetwork.NewPublicIPAddressesClient("sub-1", fakeCred{}, opts) + if err != nil { + t.Fatal(err) + } + + poller, err := pipClient.BeginCreateOrUpdate(ctx, "rg-1", "pip-1", + armnetwork.PublicIPAddress{ + Location: to.Ptr("eastus"), + SKU: &armnetwork.PublicIPAddressSKU{ + Name: to.Ptr(armnetwork.PublicIPAddressSKUNameStandard), + }, + Properties: &armnetwork.PublicIPAddressPropertiesFormat{ + PublicIPAllocationMethod: to.Ptr(armnetwork.IPAllocationMethodStatic), + }, + }, nil) + if err != nil { + t.Fatalf("publicIP BeginCreateOrUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: time.Millisecond}); err != nil { + t.Fatalf("publicIP poll: %v", err) + } + + got, err := pipClient.Get(ctx, "rg-1", "pip-1", nil) + if err != nil { + t.Fatalf("publicIP Get: %v", err) + } + + if got.SKU == nil || got.SKU.Name == nil || *got.SKU.Name != armnetwork.PublicIPAddressSKUNameStandard { + t.Errorf("sku.name=%v want Standard", got.SKU) + } + + if got.Properties == nil || got.Properties.PublicIPAllocationMethod == nil || + *got.Properties.PublicIPAllocationMethod != armnetwork.IPAllocationMethodStatic { + t.Errorf("publicIPAllocationMethod=%v want Static", got.Properties) + } + + if got.Properties == nil || got.Properties.IPAddress == nil || *got.Properties.IPAddress == "" { + t.Errorf("ipAddress empty, want non-empty") + } +} + func TestSDKNSGRoundTrip(t *testing.T) { cloudP := cloudemu.NewAzure() srv := azureserver.New(azureserver.Drivers{Network: cloudP.VNet}) diff --git a/server/azure/network/types.go b/server/azure/network/types.go index 4f44e512..2b79833d 100644 --- a/server/azure/network/types.go +++ b/server/azure/network/types.go @@ -105,3 +105,38 @@ type nsgResponseProps struct { type nsgListResponse struct { Value []nsgResponse `json:"value"` } + +type publicIPRequest struct { + Location string `json:"location"` + Tags map[string]string `json:"tags,omitempty"` + SKU *publicIPSKU `json:"sku,omitempty"` + Properties publicIPReqProps `json:"properties"` +} + +type publicIPSKU struct { + Name string `json:"name,omitempty"` +} + +type publicIPReqProps struct { + PublicIPAllocationMethod string `json:"publicIPAllocationMethod,omitempty"` +} + +type publicIPResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Location string `json:"location"` + Tags map[string]string `json:"tags,omitempty"` + SKU *publicIPSKU `json:"sku,omitempty"` + Properties publicIPRespProps `json:"properties"` +} + +type publicIPRespProps struct { + ProvisioningState string `json:"provisioningState"` + PublicIPAllocationMethod string `json:"publicIPAllocationMethod,omitempty"` + IPAddress string `json:"ipAddress,omitempty"` +} + +type publicIPListResponse struct { + Value []publicIPResponse `json:"value"` +} diff --git a/server/azure/resourcegraph/arg_cost_fields_test.go b/server/azure/resourcegraph/arg_cost_fields_test.go new file mode 100644 index 00000000..4f33daa5 --- /dev/null +++ b/server/azure/resourcegraph/arg_cost_fields_test.go @@ -0,0 +1,554 @@ +// Real-SDK ARG round-trip tests for the cost-relevant fields that flow through +// Resource Graph across compute/network, managed-data, storage/database, and App +// Service plans. The live armresourcegraph client drives the in-memory handler +// end-to-end and asserts the sku/kind/properties slots an offline cost consumer +// prices on. +// +// Shared helpers (fakeCred, newResourceGraphClient) come from sdk_test.go in this +// same test package. +package resourcegraph_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2" + azureprovider "github.com/stackshy/cloudemu/v2/providers/azure" + "github.com/stackshy/cloudemu/v2/providers/azure/aks" + "github.com/stackshy/cloudemu/v2/providers/azure/functions" + "github.com/stackshy/cloudemu/v2/providers/azure/virtualmachines" + azureserver "github.com/stackshy/cloudemu/v2/server/azure" + computedriver "github.com/stackshy/cloudemu/v2/services/compute/driver" + dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" + dbxdriver "github.com/stackshy/cloudemu/v2/services/databricks/driver" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" + rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" + storagedriver "github.com/stackshy/cloudemu/v2/services/storage/driver" +) + +// argCostClient wires an Azure Resource Graph handler over the provider's +// discovery engine and returns a real armresourcegraph client pointed at it. +// It supplies every driver the cost-field tests need — the blob/cosmos drivers +// for the storage tests and ResourceDiscovery for everything else. +func argCostClient(t *testing.T, cloudP *azureprovider.Provider) *armresourcegraph.Client { + t.Helper() + + srv := azureserver.New(azureserver.Drivers{ + BlobStorage: cloudP.BlobStorage, + CosmosDB: cloudP.CosmosDB, + ResourceDiscovery: cloudP.ResourceDiscovery, + SubscriptionID: "123456789012", + }) + ts := httptest.NewTLSServer(srv) + t.Cleanup(ts.Close) + + return newResourceGraphClient(t, ts) +} + +// queryOne runs a `Resources | where type =~ ''` query through the real +// client and requires exactly one decoded row, which it returns. +func queryOne(t *testing.T, client *armresourcegraph.Client, typ string) map[string]any { + t.Helper() + + out, err := client.Resources(context.Background(), armresourcegraph.QueryRequest{ + Query: to.Ptr("Resources | where type =~ '" + typ + + "' | project id,name,type,location,resourceGroup,kind,properties,sku,zones,tags"), + }, nil) + require.NoError(t, err) + + data, ok := out.Data.([]any) + require.True(t, ok, "expected []any data, got %T", out.Data) + require.Len(t, data, 1, "expected exactly one %s row", typ) + + row, ok := data[0].(map[string]any) + require.True(t, ok, "expected map row, got %T", data[0]) + + return row +} + +// rowObj asserts the value at key is a JSON object and returns it. +func rowObj(t *testing.T, parent map[string]any, key string) map[string]any { + t.Helper() + + v, ok := parent[key] + require.True(t, ok, "missing key %q in %v", key, parent) + + m, ok := v.(map[string]any) + require.True(t, ok, "key %q is %T, want object", key, v) + + return m +} + +func rowProps(t *testing.T, row map[string]any) map[string]any { + t.Helper() + + return rowObj(t, row, "properties") +} + +func rowSKU(t *testing.T, row map[string]any) map[string]any { + t.Helper() + + return rowObj(t, row, "sku") +} + +// TestARGCostFields_Compute pins the newly-added cost/discovery fields for +// compute and network resources so they cannot silently regress. +func TestARGCostFields_Compute(t *testing.T) { + ctx := context.Background() + cloudP := cloudemu.NewAzure() + + // 1. Managed disk with provisioned performance and a tier. + _, err := cloudP.VirtualMachines.CreateVolume(ctx, computedriver.VolumeConfig{ + Size: 4, VolumeType: "PremiumV2_LRS", IOPS: 5000, Throughput: 200, Tier: "P10", + }) + require.NoError(t, err) + + // 2. VM with priority / license / OS / zone. + _, err = cloudP.VirtualMachines.RunInstances(ctx, computedriver.InstanceConfig{ + InstanceType: "Standard_D2s_v3", OSType: "Linux", + Priority: "Spot", LicenseType: "Windows_Server", Zones: []string{"1"}, + }, 1) + require.NoError(t, err) + + // 3. Public IP with the Azure defaults (Standard SKU, Static allocation). + _, err = cloudP.VNet.AllocateAddress(ctx, netdriver.ElasticIPConfig{}) + require.NoError(t, err) + + // 4. VNet + subnet carrying address prefixes. + vpc, err := cloudP.VNet.CreateVPC(ctx, netdriver.VPCConfig{CIDRBlock: "10.0.0.0/16"}) + require.NoError(t, err) + + _, err = cloudP.VNet.CreateSubnet(ctx, netdriver.SubnetConfig{ + VPCID: vpc.ID, CIDRBlock: "10.0.1.0/24", + }) + require.NoError(t, err) + + // 5. VM Scale Set with SKU + per-VM profile. + _, err = cloudP.VirtualMachines.CreateScaleSet(ctx, virtualmachines.ScaleSet{ + Name: "vmss1", SKUName: "Standard_D4s_v3", Capacity: 5, + Priority: "Spot", LicenseType: "Windows_Server", OSType: "Linux", + }) + require.NoError(t, err) + + client := argCostClient(t, cloudP) + + t.Run("disk", func(t *testing.T) { + row := queryOne(t, client, "microsoft.compute/disks") + props := rowProps(t, row) + sku := rowSKU(t, row) + + // JSON numbers decode as float64 — assert with EqualValues. + assert.EqualValues(t, 5000, props["diskIOPSReadWrite"]) + assert.EqualValues(t, 200, props["diskMBpsReadWrite"]) + assert.Equal(t, "P10", props["tier"]) + assert.Equal(t, "PremiumV2_LRS", sku["name"]) + assert.Equal(t, "P10", sku["tier"]) + }) + + t.Run("vm", func(t *testing.T) { + row := queryOne(t, client, "microsoft.compute/virtualmachines") + props := rowProps(t, row) + sku := rowSKU(t, row) + + assert.Equal(t, "Spot", props["priority"]) + assert.Equal(t, "Windows_Server", props["licenseType"]) + osDisk := rowObj(t, rowObj(t, props, "storageProfile"), "osDisk") + assert.Equal(t, "Linux", osDisk["osType"]) + assert.Equal(t, "Standard_D2s_v3", sku["name"]) + + zones, ok := row["zones"].([]any) + require.True(t, ok, "vm row has no zones array: %v", row["zones"]) + assert.Contains(t, zones, "1") + }) + + t.Run("publicip", func(t *testing.T) { + row := queryOne(t, client, "microsoft.network/publicipaddresses") + + // The type itself proves the fixed portable->Azure type map. + assert.Equal(t, "microsoft.network/publicipaddresses", row["type"]) + assert.Equal(t, "Standard", rowSKU(t, row)["name"]) + assert.Equal(t, "Static", rowProps(t, row)["publicIPAllocationMethod"]) + }) + + t.Run("vnet", func(t *testing.T) { + row := queryOne(t, client, "microsoft.network/virtualnetworks") + props := rowProps(t, row) + + addrSpace, ok := props["addressSpace"].(map[string]any) + require.True(t, ok, "vnet has no addressSpace: %v", props) + + prefixes, ok := addrSpace["addressPrefixes"].([]any) + require.True(t, ok, "addressSpace has no addressPrefixes array: %v", addrSpace) + assert.Contains(t, prefixes, "10.0.0.0/16") + }) + + t.Run("subnet", func(t *testing.T) { + row := queryOne(t, client, "microsoft.network/subnets") + assert.Equal(t, "10.0.1.0/24", rowProps(t, row)["addressPrefix"]) + }) + + t.Run("vmss", func(t *testing.T) { + row := queryOne(t, client, "microsoft.compute/virtualmachinescalesets") + sku := rowSKU(t, row) + + assert.Equal(t, "Standard_D4s_v3", sku["name"]) + assert.EqualValues(t, 5, sku["capacity"]) + + profile, ok := rowProps(t, row)["virtualMachineProfile"].(map[string]any) + require.True(t, ok, "vmss has no virtualMachineProfile: %v", row["properties"]) + assert.Equal(t, "Spot", profile["priority"]) + assert.Equal(t, "Windows_Server", profile["licenseType"]) + + storageProfile, ok := profile["storageProfile"].(map[string]any) + require.True(t, ok, "profile has no storageProfile: %v", profile) + + osDisk, ok := storageProfile["osDisk"].(map[string]any) + require.True(t, ok, "storageProfile has no osDisk: %v", storageProfile) + assert.Equal(t, "Linux", osDisk["osType"]) + }) +} + +// TestARGCostFields_ManagedInstance pins the SQL Managed Instance cost inputs: +// the backup storage redundancy (storageAccountType) and the provisioned storage +// size must round-trip through Resource Graph properties. +func TestARGCostFields_ManagedInstance(t *testing.T) { + ctx := context.Background() + cloudP := cloudemu.NewAzure() + + _, err := cloudP.SQL.CreateManagedInstance(ctx, rdsdriver.ManagedInstanceConfig{ + Name: "mi-cost", + SubnetID: "/subscriptions/123456789012/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/vn/subnets/mi", + SKUName: "GP_Gen5", + SKUTier: "GeneralPurpose", + LicenseType: "LicenseIncluded", + VCores: 8, + StorageGB: 256, + StorageAccountType: "ZoneRedundant", + }) + require.NoError(t, err) + + client := argCostClient(t, cloudP) + + row := queryOne(t, client, "microsoft.sql/managedinstances") + assert.Equal(t, "mi-cost", row["name"]) + assert.Equal(t, "GP_Gen5", rowSKU(t, row)["name"]) + + props := rowProps(t, row) + assert.Equal(t, "ZoneRedundant", props["storageAccountType"]) + assert.EqualValues(t, 256, props["storageSizeInGB"]) + assert.EqualValues(t, 8, props["vCores"]) + // The SKU tier is surfaced as properties.tier, not sku.tier: the managed + // instance discovery adapter never sets Attrs.SKUTier, so no sku.tier key + // is emitted for this type. + assert.Equal(t, "GeneralPurpose", props["tier"]) + assert.Equal(t, "LicenseIncluded", props["licenseType"]) +} + +// TestARGCostFields_MySQLFlex proves the MySQL Flexible Server projects its +// compute SKU (with a derived Burstable tier), engine version, storage size and +// HA mode through the same generic sku/properties slots. +func TestARGCostFields_MySQLFlex(t *testing.T) { + ctx := context.Background() + cloudP := cloudemu.NewAzure() + + _, err := cloudP.MySQLFlex.CreateInstance(ctx, rdsdriver.InstanceConfig{ + ID: "mysql-cost", + InstanceClass: "Standard_B1ms", + EngineVersion: "8.0.21", + AllocatedStorage: 64, + MultiAZ: true, + }) + require.NoError(t, err) + + client := argCostClient(t, cloudP) + + row := queryOne(t, client, "microsoft.dbformysql/flexibleservers") + + sku := rowSKU(t, row) + assert.Equal(t, "Standard_B1ms", sku["name"]) + assert.Equal(t, "Burstable", sku["tier"], "tier is derived from the SKU family prefix") + + props := rowProps(t, row) + assert.Equal(t, "8.0.21", props["version"]) + assert.EqualValues(t, 64, rowObj(t, props, "storage")["storageSizeGB"]) + assert.Equal(t, "ZoneRedundant", rowObj(t, props, "highAvailability")["mode"]) +} + +// TestARGCostFields_PostgresFlex mirrors TestARGCostFields_MySQLFlex for the +// PostgreSQL Flexible Server family: both flavors share the same +// appendFlexServers discovery path, so the derived tier, engine version, +// storage size, and HA mode must round-trip identically. +func TestARGCostFields_PostgresFlex(t *testing.T) { + ctx := context.Background() + cloudP := cloudemu.NewAzure() + + _, err := cloudP.PostgresFlex.CreateInstance(ctx, rdsdriver.InstanceConfig{ + ID: "postgres-cost", + InstanceClass: "Standard_B1ms", + EngineVersion: "14", + AllocatedStorage: 32, + MultiAZ: true, + }) + require.NoError(t, err) + + client := argCostClient(t, cloudP) + + row := queryOne(t, client, "microsoft.dbforpostgresql/flexibleservers") + + sku := rowSKU(t, row) + assert.Equal(t, "Standard_B1ms", sku["name"]) + assert.Equal(t, "Burstable", sku["tier"], "tier is derived from the SKU family prefix") + + props := rowProps(t, row) + assert.Equal(t, "14", props["version"]) + assert.EqualValues(t, 32, rowObj(t, props, "storage")["storageSizeGB"]) + assert.Equal(t, "ZoneRedundant", rowObj(t, props, "highAvailability")["mode"]) +} + +// TestARGCostFields_AKS pins the AKS cost inputs across the cluster (uptime-SLA +// tier, power state, kubernetes version) and its Spot agent pool +// (scaleSetPriority, node count, vmSize). +func TestARGCostFields_AKS(t *testing.T) { + ctx := context.Background() + cloudP := cloudemu.NewAzure() + + _, err := cloudP.AKS.CreateOrUpdateCluster(ctx, aks.ClusterInput{ + ResourceGroup: "rg-1", + Name: "aks-cost", + Location: "eastus", + Tier: "Standard", + AgentPools: []aks.AgentPoolInput{ + { + Name: "spotpool", + Count: 2, + VMSize: "Standard_DS2_v2", + ScaleSetPriority: "Spot", + }, + }, + }) + require.NoError(t, err) + + client := argCostClient(t, cloudP) + + t.Run("cluster row carries tier, powerState and kubernetesVersion", func(t *testing.T) { + row := queryOne(t, client, "microsoft.containerservice/managedclusters") + assert.Equal(t, "aks-cost", row["name"]) + assert.Equal(t, "Standard", rowSKU(t, row)["tier"]) + + props := rowProps(t, row) + assert.Equal(t, "Running", rowObj(t, props, "powerState")["code"]) + assert.NotEmpty(t, props["kubernetesVersion"], "kubernetes version must be set") + }) + + t.Run("agent pool row carries Spot priority, count and vmSize", func(t *testing.T) { + row := queryOne(t, client, "microsoft.containerservice/managedclusters/agentpools") + assert.Equal(t, "Standard_DS2_v2", rowSKU(t, row)["name"]) + + props := rowProps(t, row) + assert.Equal(t, "Spot", props["scaleSetPriority"]) + assert.EqualValues(t, 2, props["count"]) + assert.Equal(t, "User", props["mode"], "inline pools default to the User mode") + assert.Equal(t, "Linux", props["osType"], "inline pools default to the Linux osType") + }) +} + +// TestARGCostFields_Databricks asserts the Databricks workspace SKU (the pricing +// tier) round-trips, along with the provisioning state / workspace id the mock +// populates on create. +func TestARGCostFields_Databricks(t *testing.T) { + ctx := context.Background() + cloudP := cloudemu.NewAzure() + + _, err := cloudP.Databricks.CreateWorkspace(ctx, dbxdriver.WorkspaceConfig{ + Name: "dbx-cost", + ResourceGroup: "rg-1", + Location: "eastus", + SKUName: "premium", + SKUTier: "premium", + ManagedResourceGroupID: "/subscriptions/123456789012/resourceGroups/databricks-rg-1", + }) + require.NoError(t, err) + + client := argCostClient(t, cloudP) + + row := queryOne(t, client, "microsoft.databricks/workspaces") + assert.Equal(t, "dbx-cost", row["name"]) + + sku := rowSKU(t, row) + assert.Equal(t, "premium", sku["name"]) + + assert.Equal(t, "premium", sku["tier"]) + + // provisioningState and workspaceId are populated by the mock on create, so + // the properties bag must surface them for a cost/discovery consumer. + props := rowProps(t, row) + assert.NotEmpty(t, props["provisioningState"]) + assert.NotEmpty(t, props["workspaceId"]) +} + +// TestARGCostFields_SQLDatabase proves a logical SQL database created on a SQL +// server surfaces through Resource Graph with the cost-relevant SKU and +// zone-redundancy fields. The sqlDiscovery adapter enumerates databases per +// server by calling ListDatabases(server) where server == the SQL server's ID, +// so the database's Server must match the seeded server's ID to be discovered. +func TestARGCostFields_SQLDatabase(t *testing.T) { + ctx := context.Background() + cloudP := cloudemu.NewAzure() + + server, err := cloudP.SQL.CreateCluster(ctx, rdsdriver.ClusterConfig{ + ID: "sql-cost", + MasterUsername: "admin", + EngineVersion: "12.0", + }) + require.NoError(t, err) + + _, err = cloudP.SQL.CreateDatabase(ctx, rdsdriver.DatabaseConfig{ + Server: server.ID, + Name: "appdb", + SKUName: "GP_Gen5_4", + SKUTier: "GeneralPurpose", + ZoneRedundant: true, + }) + require.NoError(t, err) + + client := argCostClient(t, cloudP) + + row := queryOne(t, client, "microsoft.sql/servers/databases") + assert.Equal(t, "appdb", row["name"]) + + sku := rowSKU(t, row) + assert.Equal(t, "GP_Gen5_4", sku["name"]) + + props := rowProps(t, row) + zr, ok := props["zoneRedundant"].(bool) + require.True(t, ok, "zoneRedundant is %T, want bool", props["zoneRedundant"]) + assert.True(t, zr) + + currentSku := rowObj(t, props, "currentSku") + assert.Equal(t, "GP_Gen5_4", currentSku["name"]) + assert.Equal(t, "GeneralPurpose", currentSku["tier"]) +} + +// TestARGCostFields_SQLServer proves the SQL logical server itself (not just +// the databases hosted on it) surfaces its engine version through Resource +// Graph, as microsoft.sql/servers -> properties.version. +func TestARGCostFields_SQLServer(t *testing.T) { + ctx := context.Background() + cloudP := cloudemu.NewAzure() + + server, err := cloudP.SQL.CreateCluster(ctx, rdsdriver.ClusterConfig{ + ID: "sql-server-cost", + MasterUsername: "admin", + EngineVersion: "12.0", + }) + require.NoError(t, err) + + client := argCostClient(t, cloudP) + + row := queryOne(t, client, "microsoft.sql/servers") + assert.Equal(t, server.ID, row["name"]) + assert.Equal(t, server.EngineVersion, rowProps(t, row)["version"]) +} + +// TestARGCostFields_StorageAccount proves a blob container's seeded +// storage-account attributes (SKU redundancy, kind, access tier) round-trip +// through Resource Graph as the top-level kind, sku.name, and +// properties.accessTier a cost consumer prices on. +func TestARGCostFields_StorageAccount(t *testing.T) { + ctx := context.Background() + cloudP := cloudemu.NewAzure() + + require.NoError(t, cloudP.BlobStorage.CreateBucket(ctx, "mybucket")) + cloudP.BlobStorage.SetBucketAttributes("mybucket", storagedriver.AccountAttributes{ + SKU: "Premium_LRS", + Kind: "BlockBlobStorage", + AccessTier: "Hot", + }) + + client := argCostClient(t, cloudP) + + row := queryOne(t, client, "microsoft.storage/storageaccounts") + assert.Equal(t, "mybucket", row["name"]) + assert.Equal(t, "BlockBlobStorage", row["kind"]) + assert.Equal(t, "Premium_LRS", rowSKU(t, row)["name"]) + + props := rowProps(t, row) + assert.Equal(t, "Hot", props["accessTier"]) +} + +// TestARGCostFields_CosmosAccount proves a Cosmos container's seeded account +// attributes (kind, offer type, capabilities, free-tier flag) round-trip through +// Resource Graph as the top-level kind and the +// properties.databaseAccountOfferType / capabilities[].name / enableFreeTier +// fields a cost consumer prices on. +func TestARGCostFields_CosmosAccount(t *testing.T) { + ctx := context.Background() + cloudP := cloudemu.NewAzure() + + require.NoError(t, cloudP.CosmosDB.CreateTable(ctx, dbdriver.TableConfig{ + Name: "events", PartitionKey: "pk", + })) + cloudP.CosmosDB.SetTableAttributes("events", dbdriver.AccountAttributes{ + Kind: "GlobalDocumentDB", + OfferType: "Standard", + EnableFreeTier: true, + Capabilities: []string{"EnableServerless"}, + }) + + client := argCostClient(t, cloudP) + + row := queryOne(t, client, "microsoft.documentdb/databaseaccounts") + assert.Equal(t, "events", row["name"]) + assert.Equal(t, "GlobalDocumentDB", row["kind"]) + + props := rowProps(t, row) + assert.Equal(t, "Standard", props["databaseAccountOfferType"]) + + freeTier, ok := props["enableFreeTier"].(bool) + require.True(t, ok, "enableFreeTier is %T, want bool", props["enableFreeTier"]) + assert.True(t, freeTier) + + caps, ok := props["capabilities"].([]any) + require.True(t, ok, "capabilities is %T, want []any", props["capabilities"]) + require.Len(t, caps, 1) + + firstCap, ok := caps[0].(map[string]any) + require.True(t, ok, "capabilities[0] is %T, want object", caps[0]) + assert.Equal(t, "EnableServerless", firstCap["name"]) +} + +// TestARGCostFields_AppServicePlan pins the App Service plan cost inputs: the +// SKU name/tier/capacity and the plan kind must round-trip through Resource +// Graph as microsoft.web/serverfarms. +func TestARGCostFields_AppServicePlan(t *testing.T) { + ctx := context.Background() + cloudP := cloudemu.NewAzure() + + _, err := cloudP.Functions.CreateAppServicePlan(ctx, functions.AppServicePlan{ + Name: "plan1", + SKUName: "P1v3", + SKUTier: "PremiumV3", + Kind: "linux", + Capacity: 3, + }) + require.NoError(t, err) + + client := argCostClient(t, cloudP) + + row := queryOne(t, client, "microsoft.web/serverfarms") + assert.Equal(t, "plan1", row["name"]) + assert.Equal(t, "microsoft.web/serverfarms", row["type"]) + assert.Equal(t, "linux", row["kind"]) + + sku := rowSKU(t, row) + assert.Equal(t, "P1v3", sku["name"]) + assert.Equal(t, "PremiumV3", sku["tier"]) + // JSON numbers decode as float64 through the any-typed row. + assert.EqualValues(t, 3, sku["capacity"]) +} diff --git a/server/azure/resourcegraph/handler.go b/server/azure/resourcegraph/handler.go index 2a984cf5..7a24d991 100644 --- a/server/azure/resourcegraph/handler.go +++ b/server/azure/resourcegraph/handler.go @@ -108,7 +108,7 @@ func (h *Handler) queryResources(w http.ResponseWriter, r *http.Request) { data := make([]map[string]any, 0, len(results)) for i := range results { - data = append(data, resourceToWire(&results[i])) + data = append(data, h.resourceToWire(&results[i])) } azurearm.WriteJSON(w, http.StatusOK, map[string]any{ @@ -208,23 +208,89 @@ func applyLimit(results []resourcediscovery.Resource, kqlLimit, top, skip int) [ return results } -// resourceToWire formats one Resource into the Azure Resource Graph row -// shape: { id, name, type, location, resourceGroup, subscriptionId, tags }. -// The portable Type is translated back to the canonical Azure type string. -func resourceToWire(r *resourcediscovery.Resource) map[string]any { +// resourceToWire formats one Resource into the Azure Resource Graph row shape. +// The fixed columns (id, name, type, location, resourceGroup, subscriptionId, +// tags) are always present; the resource-shape columns (sku, properties, +// managedBy, kind, zones) are emitted from the Resource's generic attribute +// slots only when set — the same rendering for every resource type, with no +// per-type branching. id is the ARM resource ID and resourceGroup is derived +// from it (real Resource Graph consumers parse both). +func (h *Handler) resourceToWire(r *resourcediscovery.Resource) map[string]any { + // The emulator is single-subscription: every resource belongs to the + // configured subscription the ARM clients use. Stamp that, rather than + // parsing it out of each mock's ARN — those embed inconsistent placeholders + // (empty, a region, a zero id), which would make a subscription-scoped ARG + // query return nothing. Fall back to the ARN only when unset. + subscription := h.subscriptionID + if subscription == "" { + subscription = extractSubscription(r.ARN) + } + out := map[string]any{ "id": r.ARN, "name": r.ID, "type": portableToAzureType(r.Service, r.Type), "location": r.Region, - "resourceGroup": "default", - "subscriptionId": extractSubscription(r.ARN), + "resourceGroup": resourceGroupOrDefault(r.ARN), + "subscriptionId": subscription, "tags": tagsOrEmpty(r.Tags), } + if r.SKU != "" || r.SKUTier != "" || r.SKUCapacity > 0 { + sku := map[string]any{} + if r.SKU != "" { + sku["name"] = r.SKU + } + + if r.SKUTier != "" { + sku["tier"] = r.SKUTier + } + + if r.SKUCapacity > 0 { + sku["capacity"] = r.SKUCapacity + } + + out["sku"] = sku + } + + if r.ManagedBy != "" { + out["managedBy"] = r.ManagedBy + } + + if r.Kind != "" { + out["kind"] = r.Kind + } + + if len(r.Zones) > 0 { + out["zones"] = r.Zones + } + + if len(r.Properties) > 0 { + out["properties"] = r.Properties + } + return out } +// resourceGroupOrDefault pulls the resource group out of an Azure resource ID +// (/subscriptions//resourceGroups//...), case-insensitively. Falls back +// to "default" for IDs that don't carry one. +func resourceGroupOrDefault(id string) string { + const key = "/resourcegroups/" + + i := strings.Index(strings.ToLower(id), key) + if i < 0 { + return "default" + } + + rest := id[i+len(key):] + if j := strings.IndexByte(rest, '/'); j >= 0 { + return rest[:j] + } + + return rest +} + func tagsOrEmpty(tags map[string]string) map[string]string { if tags == nil { return map[string]string{} @@ -255,17 +321,23 @@ func extractSubscription(arn string) string { // pairs grow. var portableToAzureTypeMap = map[string]string{ //nolint:gochecknoglobals // static lookup table "compute/Instance": "microsoft.compute/virtualmachines", + "compute/Volume": "microsoft.compute/disks", + "compute/ScaleSet": "microsoft.compute/virtualmachinescalesets", "networking/VPC": "microsoft.network/virtualnetworks", "networking/Subnet": "microsoft.network/subnets", "networking/SecurityGroup": "microsoft.network/networksecuritygroups", + "networking/NetworkInterface": "microsoft.network/networkinterfaces", + "networking/ElasticIP": "microsoft.network/publicipaddresses", "storage/Bucket": "microsoft.storage/storageaccounts", "database/Table": "microsoft.documentdb/databaseaccounts", "serverless/Function": "microsoft.web/sites", + "appservice/AppServicePlan": "microsoft.web/serverfarms", "databricks/Workspace": "microsoft.databricks/workspaces", "kubernetes/Cluster": "microsoft.containerservice/managedclusters", "kubernetes/NodeGroup": "microsoft.containerservice/managedclusters/agentpools", "relationaldb/SqlServer": "microsoft.sql/servers", "relationaldb/SqlManagedInstance": "microsoft.sql/managedinstances", + "relationaldb/SqlDatabase": "microsoft.sql/servers/databases", "relationaldb/MySqlFlexibleServer": "microsoft.dbformysql/flexibleservers", "relationaldb/PostgresFlexibleServer": "microsoft.dbforpostgresql/flexibleservers", } diff --git a/server/azure/resourcegraph/kql.go b/server/azure/resourcegraph/kql.go index 8d70a783..7f7a1028 100644 --- a/server/azure/resourcegraph/kql.go +++ b/server/azure/resourcegraph/kql.go @@ -35,20 +35,26 @@ import ( // and emitted in response rows. const ( azureTypeVM = "microsoft.compute/virtualmachines" + azureTypeDisk = "microsoft.compute/disks" + azureTypeVMSS = "microsoft.compute/virtualmachinescalesets" azureTypeVNet = "microsoft.network/virtualnetworks" azureTypeSubnet = "microsoft.network/subnets" azureTypeSubnetN = "microsoft.network/virtualnetworks/subnets" azureTypeNSG = "microsoft.network/networksecuritygroups" + azureTypeNIC = "microsoft.network/networkinterfaces" + azureTypePublicIP = "microsoft.network/publicipaddresses" azureTypeStorage = "microsoft.storage/storageaccounts" azureTypeStoCnt = "microsoft.storage/storageaccounts/blobservices/containers" azureTypeCosmos = "microsoft.documentdb/databaseaccounts" azureTypeCosmosC = "microsoft.documentdb/databaseaccounts/sqldatabases/containers" azureTypeWebSite = "microsoft.web/sites" + azureTypeServerfrm = "microsoft.web/serverfarms" azureTypeDatabrick = "microsoft.databricks/workspaces" azureTypeAKS = "microsoft.containerservice/managedclusters" azureTypeAgentPool = "microsoft.containerservice/managedclusters/agentpools" azureTypeSQL = "microsoft.sql/servers" azureTypeSQLMI = "microsoft.sql/managedinstances" + azureTypeSQLDB = "microsoft.sql/servers/databases" azureTypeMySQLFlex = "microsoft.dbformysql/flexibleservers" azureTypePgFlex = "microsoft.dbforpostgresql/flexibleservers" ) @@ -60,6 +66,7 @@ const ( portableStorage = "storage" portableDatabase = "database" portableServerless = "serverless" + portableAppService = "appservice" portableDatabricks = "databricks" portableKubernetes = "kubernetes" portableRelationalDB = "relationaldb" @@ -298,20 +305,26 @@ type portableResourceType struct{ service, typ string } // the gate as the type list grows. var azureToPortableType = map[string]portableResourceType{ //nolint:gochecknoglobals // static lookup table azureTypeVM: {portableCompute, "Instance"}, + azureTypeDisk: {portableCompute, "Volume"}, + azureTypeVMSS: {portableCompute, "ScaleSet"}, azureTypeVNet: {portableNetworking, "VPC"}, azureTypeSubnet: {portableNetworking, "Subnet"}, azureTypeSubnetN: {portableNetworking, "Subnet"}, azureTypeNSG: {portableNetworking, "SecurityGroup"}, + azureTypeNIC: {portableNetworking, "NetworkInterface"}, + azureTypePublicIP: {portableNetworking, "ElasticIP"}, azureTypeStorage: {portableStorage, "Bucket"}, azureTypeStoCnt: {portableStorage, "Bucket"}, azureTypeCosmos: {portableDatabase, "Table"}, azureTypeCosmosC: {portableDatabase, "Table"}, azureTypeWebSite: {portableServerless, "Function"}, + azureTypeServerfrm: {portableAppService, "AppServicePlan"}, azureTypeDatabrick: {portableDatabricks, "Workspace"}, azureTypeAKS: {portableKubernetes, "Cluster"}, azureTypeAgentPool: {portableKubernetes, "NodeGroup"}, azureTypeSQL: {portableRelationalDB, "SqlServer"}, azureTypeSQLMI: {portableRelationalDB, "SqlManagedInstance"}, + azureTypeSQLDB: {portableRelationalDB, "SqlDatabase"}, azureTypeMySQLFlex: {portableRelationalDB, "MySqlFlexibleServer"}, azureTypePgFlex: {portableRelationalDB, "PostgresFlexibleServer"}, } diff --git a/server/azure/resourcegraph/sdk_test.go b/server/azure/resourcegraph/sdk_test.go index 077e3e88..76e9c21e 100644 --- a/server/azure/resourcegraph/sdk_test.go +++ b/server/azure/resourcegraph/sdk_test.go @@ -21,6 +21,7 @@ import ( "github.com/stackshy/cloudemu/v2" "github.com/stackshy/cloudemu/v2/providers/azure/aks" azureserver "github.com/stackshy/cloudemu/v2/server/azure" + computedriver "github.com/stackshy/cloudemu/v2/services/compute/driver" dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" dbxdriver "github.com/stackshy/cloudemu/v2/services/databricks/driver" netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" @@ -162,6 +163,109 @@ func TestSDKResourceGraph(t *testing.T) { }) } +// TestSDKResourceGraph_ResourceFields pins issue #315: Resource Graph rows must +// carry resource-type-specific fields (VM sku.name; managed-disk rows with +// sku.name, properties.diskSizeGB, and managedBy) so a real discovery + cost +// consumer can price SKU/size-sensitive resources offline. +func TestSDKResourceGraph_ResourceFields(t *testing.T) { + ctx := context.Background() + cloudP := cloudemu.NewAzure() + + insts, err := cloudP.VirtualMachines.RunInstances(ctx, + computedriver.InstanceConfig{InstanceType: "Standard_D2s_v3"}, 1) + require.NoError(t, err) + require.Len(t, insts, 1) + + vol, err := cloudP.VirtualMachines.CreateVolume(ctx, + computedriver.VolumeConfig{Size: 4, VolumeType: "Premium_LRS"}) + require.NoError(t, err) + require.NoError(t, cloudP.VirtualMachines.AttachVolume(ctx, vol.ID, insts[0].ID, "/dev/sda")) + + srv := azureserver.New(azureserver.Drivers{ + ResourceDiscovery: cloudP.ResourceDiscovery, + SubscriptionID: "123456789012", + }) + ts := httptest.NewTLSServer(srv) + t.Cleanup(ts.Close) + + client := newResourceGraphClient(t, ts) + + out, err := client.Resources(ctx, armresourcegraph.QueryRequest{ + Query: to.Ptr("Resources | where type in~ ('microsoft.compute/virtualmachines'," + + "'microsoft.compute/disks') | project id,name,type,location,resourceGroup,managedBy,properties,sku,tags,zones"), + }, nil) + require.NoError(t, err) + + data := out.Data.([]any) + require.Len(t, data, 2, "expect the VM and its disk") + + vm := findRowByType(t, data, "microsoft.compute/virtualmachines") + disk := findRowByType(t, data, "microsoft.compute/disks") + + // VM: sku.name is the VM size; id is an ARM-shaped resource id. + assert.Equal(t, "Standard_D2s_v3", vm["sku"].(map[string]any)["name"]) + assert.Contains(t, vm["id"], "/providers/Microsoft.Compute/virtualMachines/") + assert.Equal(t, insts[0].ID, vm["name"], "name is the short resource name") + + // Disk: sku.name = tier, properties.diskSizeGB = provisioned size, managedBy + // = the owning VM's id (JSON numbers decode as float64). + assert.Equal(t, "Premium_LRS", disk["sku"].(map[string]any)["name"]) + assert.EqualValues(t, 4, disk["properties"].(map[string]any)["diskSizeGB"]) + assert.Equal(t, vm["id"], disk["managedBy"], "disk managedBy links to the VM id") +} + +// TestSDKResourceGraph_FlexServerFields proves the generic attribute mechanism +// is not VM/disk-specific: a PostgreSQL Flexible Server projects its compute +// SKU and HA mode through the same slots, rendered the same way. +func TestSDKResourceGraph_FlexServerFields(t *testing.T) { + ctx := context.Background() + cloudP := cloudemu.NewAzure() + + _, err := cloudP.PostgresFlex.CreateInstance(ctx, rdsdriver.InstanceConfig{ + ID: "pg-flex-1", InstanceClass: "Standard_D2ds_v5", AllocatedStorage: 128, MultiAZ: true, + }) + require.NoError(t, err) + + srv := azureserver.New(azureserver.Drivers{ + ResourceDiscovery: cloudP.ResourceDiscovery, + SubscriptionID: "123456789012", + }) + ts := httptest.NewTLSServer(srv) + t.Cleanup(ts.Close) + + client := newResourceGraphClient(t, ts) + + out, err := client.Resources(ctx, armresourcegraph.QueryRequest{ + Query: to.Ptr("Resources | where type =~ 'microsoft.dbforpostgresql/flexibleservers'"), + }, nil) + require.NoError(t, err) + + data := out.Data.([]any) + require.Len(t, data, 1) + + row := data[0].(map[string]any) + assert.Equal(t, "Standard_D2ds_v5", row["sku"].(map[string]any)["name"]) + + props := row["properties"].(map[string]any) + assert.Equal(t, "ZoneRedundant", props["highAvailability"].(map[string]any)["mode"]) + assert.EqualValues(t, 128, props["storage"].(map[string]any)["storageSizeGB"]) +} + +func findRowByType(t *testing.T, data []any, typ string) map[string]any { + t.Helper() + + for _, d := range data { + row := d.(map[string]any) + if row["type"] == typ { + return row + } + } + + t.Fatalf("no row of type %q in %v", typ, data) + + return nil +} + // TestSDKResourceGraph_DatabricksIndexing pins issue #225: Databricks ARM // workspaces must appear in Resource Graph results (they are wired as a // service driver but were not fed into the discovery inventory), and the diff --git a/server/azure/storageaccount/handler.go b/server/azure/storageaccount/handler.go new file mode 100644 index 00000000..af8cba79 --- /dev/null +++ b/server/azure/storageaccount/handler.go @@ -0,0 +1,212 @@ +// Package storageaccount implements the Azure Storage-account ARM control plane +// (Microsoft.Storage/storageAccounts) as a server.Handler. Real +// github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage +// AccountsClient clients configured with a custom endpoint hit this handler the +// same way they hit management.azure.com. +// +// This is the management-plane counterpart to the blob data-plane handler: an +// account name maps to a driver bucket, and the account's SKU / kind / +// access-tier cost attributes are stored via the driver's optional +// BucketAttributes capability so a discovery + cost consumer can price it. +// +// Coverage: +// +// PUT .../providers/Microsoft.Storage/storageAccounts/{name} — create/update +// GET .../providers/Microsoft.Storage/storageAccounts/{name} — get +// DELETE .../providers/Microsoft.Storage/storageAccounts/{name} — delete +// +// Create is a long-running operation in real Azure; the emulator completes it +// synchronously by returning 200 with the resource body inline so the SDK's LRO +// poller terminates on the first response. +package storageaccount + +import ( + "context" + "net/http" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" + storagedriver "github.com/stackshy/cloudemu/v2/services/storage/driver" +) + +const ( + providerName = "Microsoft.Storage" + resourceType = "storageAccounts" + defaultLocation = "eastus" + + skuStandardPrefix = "Standard" + skuPremiumPrefix = "Premium" +) + +// attrBackend is the optional storage-account attribute capability. The Azure +// blob mock implements it; S3/GCS buckets don't (their ARM control plane is not +// served here anyway). +type attrBackend interface { + SetBucketAttributes(name string, attrs storagedriver.AccountAttributes) + BucketAttributes(ctx context.Context, bucket string) (storagedriver.AccountAttributes, error) +} + +// Handler serves Microsoft.Storage/storageAccounts ARM requests against a +// storage bucket driver. +type Handler struct { + bucket storagedriver.Bucket + attrs attrBackend // nil when the driver doesn't expose account attributes +} + +// New returns a storage-account handler backed by b. +func New(b storagedriver.Bucket) *Handler { + h := &Handler{bucket: b} + if a, ok := b.(attrBackend); ok { + h.attrs = a + } + + return h +} + +// Matches claims only the ARM management path for storage accounts. It never +// claims the blob data-plane path (blob.core.windows.net-style URLs never start +// with /subscriptions/), so blob routing is undisturbed. +func (*Handler) Matches(r *http.Request) bool { + rp, ok := azurearm.ParsePath(r.URL.Path) + if !ok { + return false + } + + return strings.EqualFold(rp.Provider, providerName) && + strings.EqualFold(rp.ResourceType, resourceType) +} + +// ServeHTTP routes the request based on path shape and method. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + rp, ok := azurearm.ParsePath(r.URL.Path) + if !ok { + azurearm.WriteError(w, http.StatusBadRequest, "InvalidPath", "malformed ARM path") + return + } + + if rp.ResourceName == "" { + azurearm.WriteError(w, http.StatusNotFound, "NotFound", "storage account name required") + return + } + + switch r.Method { + case http.MethodPut: + h.createOrUpdate(w, r, &rp) + case http.MethodGet: + h.get(w, r, &rp) + case http.MethodDelete: + h.deleteAccount(w, r, &rp) + default: + azurearm.WriteError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") + } +} + +func (h *Handler) createOrUpdate(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + var body armAccountCreate + if !azurearm.DecodeJSON(w, r, &body) { + return + } + + name := rp.ResourceName + + // Upsert: an existing account (bucket) re-applies its cost attributes rather + // than erroring, matching real Azure's create-or-update semantics. + if err := h.bucket.CreateBucket(r.Context(), name); err != nil && !cerrors.IsAlreadyExists(err) { + azurearm.WriteCErr(w, err) + return + } + + attrs := storagedriver.AccountAttributes{Kind: body.Kind} + if body.SKU != nil { + attrs.SKU = body.SKU.Name + } + + if body.Properties != nil { + attrs.AccessTier = body.Properties.AccessTier + } + + if h.attrs != nil { + h.attrs.SetBucketAttributes(name, attrs) + } + + azurearm.WriteJSON(w, http.StatusOK, h.toARMAccount(r.Context(), rp, body.Location, body.Tags)) +} + +func (h *Handler) get(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if !h.bucketExists(r.Context(), rp.ResourceName) { + azurearm.WriteError(w, http.StatusNotFound, "ResourceNotFound", + "storage account "+rp.ResourceName+" not found") + return + } + + azurearm.WriteJSON(w, http.StatusOK, h.toARMAccount(r.Context(), rp, defaultLocation, nil)) +} + +func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if err := h.bucket.DeleteBucket(r.Context(), rp.ResourceName); err != nil && !cerrors.IsNotFound(err) { + azurearm.WriteCErr(w, err) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (h *Handler) bucketExists(ctx context.Context, name string) bool { + buckets, err := h.bucket.ListBuckets(ctx) + if err != nil { + return false + } + + for _, b := range buckets { + if b.Name == name { + return true + } + } + + return false +} + +// toARMAccount renders the ARM storage-account wire shape, reading the stored +// cost attributes (SKU / kind / access tier) back through the driver. +func (h *Handler) toARMAccount( + ctx context.Context, rp *azurearm.ResourcePath, location string, tags map[string]string, +) armAccount { + attrs := storagedriver.AccountAttributes{SKU: "Standard_LRS", Kind: "StorageV2", AccessTier: "Hot"} + if h.attrs != nil { + if a, err := h.attrs.BucketAttributes(ctx, rp.ResourceName); err == nil { + attrs = a + } + } + + if location == "" { + location = defaultLocation + } + + return armAccount{ + ID: azurearm.BuildResourceID(rp.Subscription, rp.ResourceGroup, providerName, resourceType, rp.ResourceName), + Name: rp.ResourceName, + Type: providerName + "/" + resourceType, + Location: location, + Kind: attrs.Kind, + Tags: tags, + SKU: &armSKU{Name: attrs.SKU, Tier: skuTier(attrs.SKU)}, + Properties: &armAccountProps{ + AccessTier: attrs.AccessTier, + ProvisioningState: "Succeeded", + }, + } +} + +// skuTier derives the read-only SKU tier from the SKU name (Standard_LRS -> +// Standard, Premium_LRS -> Premium). +func skuTier(sku string) string { + switch { + case strings.HasPrefix(sku, skuPremiumPrefix): + return skuPremiumPrefix + case strings.HasPrefix(sku, skuStandardPrefix): + return skuStandardPrefix + default: + return "" + } +} diff --git a/server/azure/storageaccount/sdk_test.go b/server/azure/storageaccount/sdk_test.go new file mode 100644 index 00000000..50b8b9de --- /dev/null +++ b/server/azure/storageaccount/sdk_test.go @@ -0,0 +1,114 @@ +// Real-SDK round-trip test: the live azure-sdk-for-go armstorage +// AccountsClient drives the in-memory handler end-to-end, proving the +// storage-account cost fields (sku.name, kind, properties.accessTier) survive a +// create -> get. + +package storageaccount_test + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2" + azureserver "github.com/stackshy/cloudemu/v2/server/azure" +) + +type fakeCred struct{} + +func (fakeCred) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "fake", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +func newAccountsClient(t *testing.T) *armstorage.AccountsClient { + t.Helper() + + cloudP := cloudemu.NewAzure() + srv := azureserver.New(azureserver.Drivers{BlobStorage: cloudP.BlobStorage}) + + ts := httptest.NewTLSServer(srv) + t.Cleanup(ts.Close) + + myCloud := cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/", + Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ + cloud.ResourceManager: { + Endpoint: ts.URL, + Audience: "https://management.azure.com", + }, + }, + } + + opts := &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Cloud: myCloud, + Transport: ts.Client(), + Retry: policy.RetryOptions{MaxRetries: -1}, + }, + } + + client, err := armstorage.NewAccountsClient("sub-1", fakeCred{}, opts) + require.NoError(t, err) + + return client +} + +func TestSDKStorageAccountCreateGet(t *testing.T) { + ctx := context.Background() + client := newAccountsClient(t) + + poller, err := client.BeginCreate(ctx, "rg-1", "acct1", armstorage.AccountCreateParameters{ + Location: to.Ptr("westus2"), + Kind: to.Ptr(armstorage.KindStorageV2), + SKU: &armstorage.SKU{Name: to.Ptr(armstorage.SKUNamePremiumLRS)}, + Properties: &armstorage.AccountPropertiesCreateParameters{ + AccessTier: to.Ptr(armstorage.AccessTierCool), + }, + Tags: map[string]*string{"env": to.Ptr("prod")}, + }, nil) + require.NoError(t, err) + + created, err := poller.PollUntilDone(ctx, nil) + require.NoError(t, err) + + // Cost fields survive the create response. + require.NotNil(t, created.SKU) + assert.Equal(t, armstorage.SKUNamePremiumLRS, *created.SKU.Name) + require.NotNil(t, created.Kind) + assert.Equal(t, armstorage.KindStorageV2, *created.Kind) + require.NotNil(t, created.Properties) + require.NotNil(t, created.Properties.AccessTier) + assert.Equal(t, armstorage.AccessTierCool, *created.Properties.AccessTier) + assert.Equal(t, "acct1", *created.Name) + + // ... and survive an independent GET. + got, err := client.GetProperties(ctx, "rg-1", "acct1", nil) + require.NoError(t, err) + + require.NotNil(t, got.SKU) + assert.Equal(t, armstorage.SKUNamePremiumLRS, *got.SKU.Name) + require.NotNil(t, got.Kind) + assert.Equal(t, armstorage.KindStorageV2, *got.Kind) + require.NotNil(t, got.Properties) + require.NotNil(t, got.Properties.AccessTier) + assert.Equal(t, armstorage.AccessTierCool, *got.Properties.AccessTier) + assert.Contains(t, *got.ID, "/providers/Microsoft.Storage/storageAccounts/acct1") +} + +func TestSDKStorageAccountGetMissing(t *testing.T) { + ctx := context.Background() + client := newAccountsClient(t) + + _, err := client.GetProperties(ctx, "rg-1", "nope", nil) + require.Error(t, err) +} diff --git a/server/azure/storageaccount/types.go b/server/azure/storageaccount/types.go new file mode 100644 index 00000000..ec916c1e --- /dev/null +++ b/server/azure/storageaccount/types.go @@ -0,0 +1,39 @@ +package storageaccount + +// armAccountCreate is the subset of the ARM storage-account create body the +// emulator reads. armstorage's AccountCreateParameters marshals to these JSON +// field names. +type armAccountCreate struct { + Location string `json:"location,omitempty"` + Kind string `json:"kind,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + SKU *armSKU `json:"sku,omitempty"` + Properties *armAccountCreateProps `json:"properties,omitempty"` +} + +type armAccountCreateProps struct { + AccessTier string `json:"accessTier,omitempty"` +} + +// armSKU is the ARM sku shape (sku.name / sku.tier). +type armSKU struct { + Name string `json:"name,omitempty"` + Tier string `json:"tier,omitempty"` +} + +// armAccount is the ARM storage-account wire shape returned on create/get. +type armAccount struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Location string `json:"location,omitempty"` + Kind string `json:"kind,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + SKU *armSKU `json:"sku,omitempty"` + Properties *armAccountProps `json:"properties,omitempty"` +} + +type armAccountProps struct { + AccessTier string `json:"accessTier,omitempty"` + ProvisioningState string `json:"provisioningState,omitempty"` +} diff --git a/server/azure/virtualmachines/handler.go b/server/azure/virtualmachines/handler.go index 3936c7b4..0bd5be83 100644 --- a/server/azure/virtualmachines/handler.go +++ b/server/azure/virtualmachines/handler.go @@ -32,6 +32,10 @@ const providerName = "Microsoft.Compute" // resourceType is the ARM resource type this handler serves. const resourceType = "virtualMachines" +// resourceTypeScaleSets is the ARM resource type for VM Scale Sets, served by +// the same handler when the backing driver exposes scale-set methods. +const resourceTypeScaleSets = "virtualMachineScaleSets" + // resourceTypeLocations is the resource type used for async operation // status endpoints (Microsoft.Compute/locations/{loc}/operationStatuses/{id}). const resourceTypeLocations = "locations" @@ -59,7 +63,7 @@ func (*Handler) Matches(r *http.Request) bool { } switch rp.ResourceType { - case resourceType, resourceTypeLocations: + case resourceType, resourceTypeScaleSets, resourceTypeLocations: return true } @@ -81,6 +85,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if rp.ResourceType == resourceTypeScaleSets { + h.serveScaleSet(w, r, rp) + return + } + switch { case rp.SubResource != "": h.serveAction(w, r, rp) diff --git a/server/azure/virtualmachines/instances.go b/server/azure/virtualmachines/instances.go index 528a7f5f..b8cd1a13 100644 --- a/server/azure/virtualmachines/instances.go +++ b/server/azure/virtualmachines/instances.go @@ -46,6 +46,10 @@ func (h *Handler) createOrUpdate(w http.ResponseWriter, r *http.Request, rp azur SubnetID: firstNicID(req.Properties.NetworkProfile), KeyName: computerName(req.Properties.OSProfile), Tags: mergeTags(req.Tags, rp.ResourceName), + Priority: req.Properties.Priority, + LicenseType: req.Properties.LicenseType, + OSType: osTypeFromStorage(req.Properties.StorageProfile), + Zones: req.Zones, } instances, err := h.compute.RunInstances(r.Context(), cfg, 1) @@ -238,6 +242,14 @@ func computerName(o *osProfile) string { return o.ComputerName } +func osTypeFromStorage(s *storageProfile) string { + if s == nil || s.OSDisk == nil { + return "" + } + + return s.OSDisk.OSType +} + func mergeTags(in map[string]string, armName string) map[string]string { out := make(map[string]string, len(in)+1) @@ -270,10 +282,14 @@ func toVMResponse(inst *computedriver.Instance, rp azurearm.ResourcePath, req vm Type: providerName + "/" + resourceType, Location: defaultIfEmpty(req.Location, "eastus"), Tags: stripInternalTags(inst.Tags), + Zones: inst.Zones, Properties: vmResponseProps{ VMID: inst.ID, ProvisioningState: "Succeeded", HardwareProfile: &hardwareProfile{VMSize: inst.InstanceType}, + StorageProfile: osDiskProfile(inst.OSType), + Priority: inst.Priority, + LicenseType: inst.LicenseType, InstanceView: &instanceView{ Statuses: []instanceViewStatus{ {Code: "ProvisioningState/succeeded", Level: "Info", DisplayStatus: "Provisioning succeeded"}, @@ -284,6 +300,17 @@ func toVMResponse(inst *computedriver.Instance, rp azurearm.ResourcePath, req vm } } +// osDiskProfile echoes the guest OS family under the storageProfile.osDisk +// path the SDK reads it from. Returns nil when the OS type is unknown so the +// field is omitted rather than emitted empty. +func osDiskProfile(osType string) *storageProfile { + if osType == "" { + return nil + } + + return &storageProfile{OSDisk: &osDisk{OSType: osType}} +} + func defaultIfEmpty(v, fallback string) string { if v == "" { return fallback diff --git a/server/azure/virtualmachines/scalesets.go b/server/azure/virtualmachines/scalesets.go new file mode 100644 index 00000000..73570279 --- /dev/null +++ b/server/azure/virtualmachines/scalesets.go @@ -0,0 +1,160 @@ +package virtualmachines + +import ( + "context" + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + providervm "github.com/stackshy/cloudemu/v2/providers/azure/virtualmachines" + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" +) + +// scaleSetStore is the subset of the Azure virtualmachines mock the handler +// type-asserts to when serving virtualMachineScaleSets. Drivers that don't +// implement it (e.g. AWS/GCP compute) fall through to 501. +type scaleSetStore interface { + CreateScaleSet(ctx context.Context, s providervm.ScaleSet) (*providervm.ScaleSet, error) + ListScaleSets(ctx context.Context) ([]providervm.ScaleSet, error) +} + +// serveScaleSet dispatches PUT/GET on Microsoft.Compute/virtualMachineScaleSets. +// +//nolint:gocritic // rp is a request-scoped value passed once per request +func (h *Handler) serveScaleSet(w http.ResponseWriter, r *http.Request, rp azurearm.ResourcePath) { + store, ok := h.compute.(scaleSetStore) + if !ok { + writeNotImplemented(w, "virtualMachineScaleSets") + return + } + + if rp.SubResource != "" { + writeNotImplemented(w, r.Method+" "+r.URL.Path) + return + } + + if rp.ResourceName == "" { + if r.Method == http.MethodGet { + listScaleSets(w, r, rp, store) + return + } + + writeNotImplemented(w, r.Method+" "+r.URL.Path) + + return + } + + switch r.Method { + case http.MethodPut: + createScaleSet(w, r, rp, store) + case http.MethodGet: + getScaleSet(w, r, rp, store) + default: + writeNotImplemented(w, r.Method+" "+r.URL.Path) + } +} + +// createScaleSet handles PUT virtualMachineScaleSets/{name}. +// +//nolint:gocritic // rp is a request-scoped value +func createScaleSet(w http.ResponseWriter, r *http.Request, rp azurearm.ResourcePath, store scaleSetStore) { + if rp.ResourceGroup == "" { + azurearm.WriteError(w, http.StatusBadRequest, "InvalidPath", "missing resourceGroups segment") + return + } + + var req vmssRequest + + if !azurearm.DecodeJSON(w, r, &req) { + return + } + + set := providervm.ScaleSet{ + Name: rp.ResourceName, + ID: azurearm.BuildResourceID(rp.Subscription, rp.ResourceGroup, providerName, resourceTypeScaleSets, rp.ResourceName), + Location: req.Location, + Tags: req.Tags, + } + + if req.SKU != nil { + set.SKUName = req.SKU.Name + set.SKUTier = req.SKU.Tier + set.Capacity = req.SKU.Capacity + } + + if p := req.Properties.VirtualMachineProfile; p != nil { + set.Priority = p.Priority + set.LicenseType = p.LicenseType + set.OSType = osTypeFromStorage(p.StorageProfile) + } + + stored, err := store.CreateScaleSet(r.Context(), set) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toVMSSResponse(stored, rp)) +} + +// getScaleSet handles GET virtualMachineScaleSets/{name}. +// +//nolint:gocritic // rp is a request-scoped value +func getScaleSet(w http.ResponseWriter, r *http.Request, rp azurearm.ResourcePath, store scaleSetStore) { + sets, err := store.ListScaleSets(r.Context()) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + for i := range sets { + if sets[i].Name == rp.ResourceName { + azurearm.WriteJSON(w, http.StatusOK, toVMSSResponse(&sets[i], rp)) + return + } + } + + azurearm.WriteCErr(w, cerrors.Newf(cerrors.NotFound, "virtualMachineScaleSet %s not found", rp.ResourceName)) +} + +// listScaleSets handles GET virtualMachineScaleSets (collection). +// +//nolint:gocritic // rp is a request-scoped value +func listScaleSets(w http.ResponseWriter, r *http.Request, rp azurearm.ResourcePath, store scaleSetStore) { + sets, err := store.ListScaleSets(r.Context()) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := make([]vmssResponse, 0, len(sets)) + + for i := range sets { + scope := rp + scope.ResourceName = sets[i].Name + out = append(out, toVMSSResponse(&sets[i], scope)) + } + + azurearm.WriteJSON(w, http.StatusOK, vmssListResponse{Value: out}) +} + +// toVMSSResponse maps a stored ScaleSet onto the ARM wire shape. +// +//nolint:gocritic // rp is a value type passed once per response build +func toVMSSResponse(s *providervm.ScaleSet, rp azurearm.ResourcePath) vmssResponse { + return vmssResponse{ + ID: azurearm.BuildResourceID(rp.Subscription, rp.ResourceGroup, providerName, resourceTypeScaleSets, s.Name), + Name: s.Name, + Type: providerName + "/" + resourceTypeScaleSets, + Location: defaultIfEmpty(s.Location, "eastus"), + Tags: s.Tags, + SKU: &vmssSKU{Name: s.SKUName, Tier: s.SKUTier, Capacity: s.Capacity}, + Properties: vmssResponseProps{ + ProvisioningState: "Succeeded", + VirtualMachineProfile: &vmssVMProfile{ + Priority: s.Priority, + LicenseType: s.LicenseType, + StorageProfile: osDiskProfile(s.OSType), + }, + }, + } +} diff --git a/server/azure/virtualmachines/scalesets_types.go b/server/azure/virtualmachines/scalesets_types.go new file mode 100644 index 00000000..0275d3c9 --- /dev/null +++ b/server/azure/virtualmachines/scalesets_types.go @@ -0,0 +1,52 @@ +package virtualmachines + +// ARM JSON request/response shapes for Microsoft.Compute/virtualMachineScaleSets. +// Only the cost-relevant surface (SKU + per-VM profile) is modeled. + +// vmssRequest is the inbound shape for PUT virtualMachineScaleSets/{name}. +type vmssRequest struct { + Location string `json:"location"` + Tags map[string]string `json:"tags,omitempty"` + SKU *vmssSKU `json:"sku,omitempty"` + Properties vmssRequestProps `json:"properties"` +} + +// vmssSKU is the scale-set SKU: VM size, tier, and instance count. +type vmssSKU struct { + Name string `json:"name,omitempty"` + Tier string `json:"tier,omitempty"` + Capacity int `json:"capacity,omitempty"` +} + +type vmssRequestProps struct { + VirtualMachineProfile *vmssVMProfile `json:"virtualMachineProfile,omitempty"` +} + +// vmssVMProfile is the per-VM template a scale set stamps out. We decode the +// cost inputs only (Spot priority, hybrid-benefit license, OS type). +type vmssVMProfile struct { + Priority string `json:"priority,omitempty"` + LicenseType string `json:"licenseType,omitempty"` + StorageProfile *storageProfile `json:"storageProfile,omitempty"` +} + +// vmssResponse is the outbound shape for a single scale set. +type vmssResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Location string `json:"location"` + Tags map[string]string `json:"tags,omitempty"` + SKU *vmssSKU `json:"sku,omitempty"` + Properties vmssResponseProps `json:"properties"` +} + +type vmssResponseProps struct { + ProvisioningState string `json:"provisioningState"` + VirtualMachineProfile *vmssVMProfile `json:"virtualMachineProfile,omitempty"` +} + +// vmssListResponse is the outbound shape for a scale-set list. +type vmssListResponse struct { + Value []vmssResponse `json:"value"` +} diff --git a/server/azure/virtualmachines/sdk_costfields_test.go b/server/azure/virtualmachines/sdk_costfields_test.go new file mode 100644 index 00000000..86c5b218 --- /dev/null +++ b/server/azure/virtualmachines/sdk_costfields_test.go @@ -0,0 +1,159 @@ +package virtualmachines_test + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v5" + "github.com/stackshy/cloudemu/v2" + azureserver "github.com/stackshy/cloudemu/v2/server/azure" +) + +// armOptions builds arm.ClientOptions pointed at the TLS test server. fakeCred +// is declared in sdk_roundtrip_test.go (same test package). +func armOptions(t *testing.T, ts *httptest.Server) *arm.ClientOptions { + t.Helper() + + return &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Cloud: cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/", + Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ + cloud.ResourceManager: {Endpoint: ts.URL, Audience: "https://management.azure.com"}, + }, + }, + Transport: ts.Client(), + Retry: policy.RetryOptions{MaxRetries: -1}, + }, + } +} + +// TestSDKVMCostFields asserts priority/licenseType/osType survive a real +// armcompute VirtualMachinesClient create -> get round-trip. +func TestSDKVMCostFields(t *testing.T) { + cloudP := cloudemu.NewAzure() + srv := azureserver.New(azureserver.Drivers{VirtualMachines: cloudP.VirtualMachines}) + + ts := httptest.NewTLSServer(srv) + t.Cleanup(ts.Close) + + client, err := armcompute.NewVirtualMachinesClient("sub-1", fakeCred{}, armOptions(t, ts)) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + + poller, err := client.BeginCreateOrUpdate(ctx, "rg-1", "cost-vm", armcompute.VirtualMachine{ + Location: to.Ptr("eastus"), + Zones: []*string{to.Ptr("1")}, + Properties: &armcompute.VirtualMachineProperties{ + HardwareProfile: &armcompute.HardwareProfile{VMSize: to.Ptr(armcompute.VirtualMachineSizeTypesStandardD2SV3)}, + Priority: to.Ptr(armcompute.VirtualMachinePriorityTypesSpot), + LicenseType: to.Ptr("Windows_Server"), + StorageProfile: &armcompute.StorageProfile{ + OSDisk: &armcompute.OSDisk{ + OSType: to.Ptr(armcompute.OperatingSystemTypesWindows), + CreateOption: to.Ptr(armcompute.DiskCreateOptionTypesFromImage), + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("BeginCreateOrUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: time.Millisecond}); err != nil { + t.Fatalf("create poll: %v", err) + } + + got, err := client.Get(ctx, "rg-1", "cost-vm", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.Properties == nil || got.Properties.Priority == nil || + *got.Properties.Priority != armcompute.VirtualMachinePriorityTypesSpot { + t.Errorf("priority=%v want Spot", got.Properties.Priority) + } + + if got.Properties.LicenseType == nil || *got.Properties.LicenseType != "Windows_Server" { + t.Errorf("licenseType=%v want Windows_Server", got.Properties.LicenseType) + } + + if got.Properties.StorageProfile == nil || got.Properties.StorageProfile.OSDisk == nil || + got.Properties.StorageProfile.OSDisk.OSType == nil || + *got.Properties.StorageProfile.OSDisk.OSType != armcompute.OperatingSystemTypesWindows { + t.Errorf("osType did not round-trip: %+v", got.Properties.StorageProfile) + } + + if len(got.Zones) != 1 || got.Zones[0] == nil || *got.Zones[0] != "1" { + t.Errorf("zones=%v want [1]", got.Zones) + } +} + +// TestSDKVMSSCostFields asserts sku.capacity + virtualMachineProfile.priority +// survive a real armcompute VirtualMachineScaleSetsClient create -> get. +func TestSDKVMSSCostFields(t *testing.T) { + cloudP := cloudemu.NewAzure() + srv := azureserver.New(azureserver.Drivers{VirtualMachines: cloudP.VirtualMachines}) + + ts := httptest.NewTLSServer(srv) + t.Cleanup(ts.Close) + + client, err := armcompute.NewVirtualMachineScaleSetsClient("sub-1", fakeCred{}, armOptions(t, ts)) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + + poller, err := client.BeginCreateOrUpdate(ctx, "rg-1", "cost-vmss", armcompute.VirtualMachineScaleSet{ + Location: to.Ptr("eastus"), + SKU: &armcompute.SKU{ + Name: to.Ptr("Standard_D2s_v3"), + Tier: to.Ptr("Standard"), + Capacity: to.Ptr[int64](3), + }, + Properties: &armcompute.VirtualMachineScaleSetProperties{ + VirtualMachineProfile: &armcompute.VirtualMachineScaleSetVMProfile{ + Priority: to.Ptr(armcompute.VirtualMachinePriorityTypesSpot), + LicenseType: to.Ptr("Windows_Server"), + StorageProfile: &armcompute.VirtualMachineScaleSetStorageProfile{ + OSDisk: &armcompute.VirtualMachineScaleSetOSDisk{ + OSType: to.Ptr(armcompute.OperatingSystemTypesWindows), + }, + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("VMSS BeginCreateOrUpdate: %v", err) + } + + if _, err := poller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: time.Millisecond}); err != nil { + t.Fatalf("VMSS create poll: %v", err) + } + + got, err := client.Get(ctx, "rg-1", "cost-vmss", nil) + if err != nil { + t.Fatalf("VMSS Get: %v", err) + } + + if got.SKU == nil || got.SKU.Capacity == nil || *got.SKU.Capacity != 3 { + t.Errorf("sku.capacity=%v want 3", got.SKU) + } + + vp := got.Properties.VirtualMachineProfile + if vp == nil || vp.Priority == nil || *vp.Priority != armcompute.VirtualMachinePriorityTypesSpot { + t.Errorf("virtualMachineProfile.priority=%v want Spot", vp) + } +} diff --git a/server/azure/virtualmachines/types.go b/server/azure/virtualmachines/types.go index 6062781d..876452ef 100644 --- a/server/azure/virtualmachines/types.go +++ b/server/azure/virtualmachines/types.go @@ -10,6 +10,7 @@ package virtualmachines type vmRequest struct { Location string `json:"location"` Tags map[string]string `json:"tags,omitempty"` + Zones []string `json:"zones,omitempty"` Properties vmRequestProps `json:"properties"` } @@ -18,6 +19,10 @@ type vmRequestProps struct { StorageProfile *storageProfile `json:"storageProfile,omitempty"` NetworkProfile *networkProfile `json:"networkProfile,omitempty"` OSProfile *osProfile `json:"osProfile,omitempty"` + // Priority ("Spot"/"Regular") and LicenseType (hybrid-benefit marker) are + // cost inputs the SDK sends under properties; we carry them to the driver. + Priority string `json:"priority,omitempty"` + LicenseType string `json:"licenseType,omitempty"` } type hardwareProfile struct { @@ -26,6 +31,12 @@ type hardwareProfile struct { type storageProfile struct { ImageReference *imageReference `json:"imageReference,omitempty"` + OSDisk *osDisk `json:"osDisk,omitempty"` +} + +// osDisk carries the OS-disk shape; we only model osType, a cost input. +type osDisk struct { + OSType string `json:"osType,omitempty"` } type imageReference struct { @@ -58,6 +69,7 @@ type vmResponse struct { Type string `json:"type"` Location string `json:"location"` Tags map[string]string `json:"tags,omitempty"` + Zones []string `json:"zones,omitempty"` Properties vmResponseProps `json:"properties"` } @@ -68,6 +80,8 @@ type vmResponseProps struct { StorageProfile *storageProfile `json:"storageProfile,omitempty"` NetworkProfile *networkProfile `json:"networkProfile,omitempty"` OSProfile *osProfile `json:"osProfile,omitempty"` + Priority string `json:"priority,omitempty"` + LicenseType string `json:"licenseType,omitempty"` InstanceView *instanceView `json:"instanceView,omitempty"` } diff --git a/server/gcp/alloydb/helpers.go b/server/gcp/alloydb/helpers.go index 6bcf022e..977bb879 100644 --- a/server/gcp/alloydb/helpers.go +++ b/server/gcp/alloydb/helpers.go @@ -100,7 +100,7 @@ func (*Handler) toWireCluster(c *rdsdriver.Cluster, info *rdsdriver.AlloyDBClust DatabaseVersion: info.DatabaseVersion, Network: info.Network, ClusterType: info.ClusterType, - State: "READY", + State: alloyDBState(c.State), Uid: c.ID, ContinuousBackupConfig: &alloydb.ContinuousBackupConfig{ Enabled: info.ContinuousBackup, @@ -125,12 +125,34 @@ func (*Handler) toWireInstance(inst *rdsdriver.Instance, info *rdsdriver.AlloyDB AvailabilityType: info.AvailabilityType, IpAddress: info.IPAddress, GceZone: info.GceZone, - State: "READY", + State: alloyDBState(inst.State), Uid: inst.ID, MachineConfig: &alloydb.MachineConfig{CpuCount: int64(info.CPUCount)}, } } +// alloyDBState maps the relationaldb driver's lifecycle state to AlloyDB's +// wire state enum, so a just-created or stopped resource reports its real +// state instead of always "READY". +const stateReady = "READY" + +func alloyDBState(driverState string) string { + switch driverState { + case rdsdriver.StateAvailable, "": + return stateReady + case rdsdriver.StateCreating, rdsdriver.StateStarting: + return "CREATING" + case rdsdriver.StateDeleting: + return "DELETING" + case rdsdriver.StateStopped, rdsdriver.StateStopping: + return "STOPPED" + case rdsdriver.StateModifying, rdsdriver.StateRebooting, rdsdriver.StateBackingUp: + return "MAINTENANCE" + default: + return stateReady + } +} + func toWireBackup(s *rdsdriver.ClusterSnapshot, backupType string) *alloydb.Backup { return &alloydb.Backup{ Name: s.ARN, diff --git a/server/gcp/artifactregistry/gapic_lro_test.go b/server/gcp/artifactregistry/gapic_lro_test.go new file mode 100644 index 00000000..da3b2708 --- /dev/null +++ b/server/gcp/artifactregistry/gapic_lro_test.go @@ -0,0 +1,57 @@ +package artifactregistry_test + +import ( + "context" + "net/http/httptest" + "testing" + + artifactregistry "cloud.google.com/go/artifactregistry/apiv1" + "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb" + "google.golang.org/api/option" + + "github.com/stackshy/cloudemu/v2" + gcpserver "github.com/stackshy/cloudemu/v2/server/gcp" +) + +// TestGAPICCreateRepositoryWait is the review's #3 check: the finding targeted +// the GAPIC apiv1 client's LRO .Wait(), which the raw google.golang.org/api +// REST client never exercised. This drives the real apiv1 REST client end to +// end — CreateRepository(...).Wait() must resolve (not 404, and not a decode +// error from a missing response @type) and return the created repository. +func TestGAPICCreateRepositoryWait(t *testing.T) { + cloud := cloudemu.NewGCP() + srv := gcpserver.New(gcpserver.DriversFrom(cloud)) // full server: exercises real dispatch + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + + ctx := context.Background() + + client, err := artifactregistry.NewRESTClient(ctx, + option.WithEndpoint(ts.URL), + option.WithoutAuthentication(), + option.WithHTTPClient(ts.Client()), + ) + if err != nil { + t.Fatalf("NewRESTClient: %v", err) + } + + t.Cleanup(func() { _ = client.Close() }) + + op, err := client.CreateRepository(ctx, &artifactregistrypb.CreateRepositoryRequest{ + Parent: "projects/demo/locations/us", + RepositoryId: "gapic-repo", + Repository: &artifactregistrypb.Repository{Description: "gapic"}, + }) + if err != nil { + t.Fatalf("CreateRepository: %v", err) + } + + repo, err := op.Wait(ctx) + if err != nil { + t.Fatalf("op.Wait (the #3 GAPIC LRO fix): %v", err) + } + + if repo == nil || repo.GetName() == "" { + t.Fatalf("Wait returned no repository: %+v", repo) + } +} diff --git a/server/gcp/artifactregistry/handler.go b/server/gcp/artifactregistry/handler.go index dd51eb6e..2dfba676 100644 --- a/server/gcp/artifactregistry/handler.go +++ b/server/gcp/artifactregistry/handler.go @@ -27,6 +27,7 @@ const ( pathPrefix = "/v1/projects/" locationsSeg = "locations" repositoriesSeg = "repositories" + operationsSeg = "operations" dockerImagesSeg = "dockerImages" ) @@ -49,6 +50,7 @@ type route struct { location string repository string // repo id; empty for the collection sub string // "dockerImages" or "" + operation string // operation id when this is an /operations/{op} path } // parseRoute extracts the components of an Artifact Registry v1 path. @@ -58,14 +60,28 @@ func parseRoute(urlPath string) (route, bool) { } parts := strings.Split(strings.TrimPrefix(urlPath, "/v1/"), "/") - // parts: [projects, {p}, locations, {l}, repositories, {id}?, {sub}?] + // parts: [projects, {p}, locations, {l}, {repositories|operations}, {id}?, {sub}?] if len(parts) < minRepoCollectionParts || - parts[0] != "projects" || parts[2] != locationsSeg || parts[4] != repositoriesSeg { + parts[0] != "projects" || parts[2] != locationsSeg { return route{}, false } rt := route{project: parts[1], location: parts[3]} + // LRO polling: GAPIC clients (.Wait()) GET the operation returned by a + // create/delete. Without this route those polls 404. + if parts[4] == operationsSeg { + if len(parts) > minRepoCollectionParts { + rt.operation = parts[5] + } + + return rt, true + } + + if parts[4] != repositoriesSeg { + return route{}, false + } + if len(parts) > minRepoCollectionParts { rt.repository = parts[5] } @@ -93,6 +109,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if rt.operation != "" { + // The mock completes operations synchronously, so any poll resolves to + // a done operation. This unblocks GAPIC .Wait() callers. Echo the exact + // operation name that was polled. + gcprest.WriteJSON(w, http.StatusOK, operationJSON{ + Name: "projects/" + rt.project + "/locations/" + rt.location + "/operations/" + rt.operation, + Done: true, + }) + + return + } + if rt.repository == "" { h.serveCollection(w, r, &rt) return diff --git a/server/gcp/artifactregistry/operations.go b/server/gcp/artifactregistry/operations.go index 8cefd501..1bc23383 100644 --- a/server/gcp/artifactregistry/operations.go +++ b/server/gcp/artifactregistry/operations.go @@ -1,6 +1,7 @@ package artifactregistry import ( + "encoding/json" "net/http" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" @@ -15,16 +16,55 @@ func (h *Handler) createRepository(w http.ResponseWriter, r *http.Request, rt *r return } + // The driver models a Docker registry and has no format/description field, + // so preserve the request's values in reserved tags and echo them on read. + tags := make(map[string]string, len(body.Labels)) + for k, v := range body.Labels { + tags[k] = v + } + + if body.Format != "" { + tags[formatTag] = body.Format + } + + if body.Description != "" { + tags[descriptionTag] = body.Description + } + repo, err := h.registry.CreateRepository(r.Context(), crdriver.RepositoryConfig{ Name: repoID, - Tags: body.Labels, + Tags: tags, }) if err != nil { gcprest.WriteCErr(w, err) return } - gcprest.WriteJSON(w, http.StatusOK, doneOperation(rt, repoID, toRepositoryJSON(rt.project, rt.location, repo))) + gcprest.WriteJSON(w, http.StatusOK, doneOperation(rt, repoID, + typedResponse(repositoryTypeURL, toRepositoryJSON(rt.project, rt.location, repo)))) +} + +// repositoryTypeURL is the protobuf Any type URL a GAPIC client expects in a +// done LRO's response so CreateRepositoryOperation.Wait() can decode it. +const repositoryTypeURL = "type.googleapis.com/google.devtools.artifactregistry.v1.Repository" + +// typedResponse renders v as the JSON object a google.protobuf.Any expects: the +// resource's fields plus an "@type" URL. Without @type a GAPIC .Wait() cannot +// unmarshal the operation response. +func typedResponse(typeURL string, v any) map[string]any { + b, err := json.Marshal(v) + if err != nil { + return nil + } + + m := map[string]any{} + if err := json.Unmarshal(b, &m); err != nil { + return nil + } + + m["@type"] = typeURL + + return m } func (h *Handler) getRepository(w http.ResponseWriter, r *http.Request, rt *route) { diff --git a/server/gcp/artifactregistry/sdk_roundtrip_test.go b/server/gcp/artifactregistry/sdk_roundtrip_test.go index 08134ad0..a34c6432 100644 --- a/server/gcp/artifactregistry/sdk_roundtrip_test.go +++ b/server/gcp/artifactregistry/sdk_roundtrip_test.go @@ -82,6 +82,46 @@ func TestSDKArtifactRegistryRepositoryLifecycle(t *testing.T) { assertGoogleAPICode(t, err, 404) } +// TestSDKArtifactRegistryOperationAndFormat guards two #321 fixes: the LRO +// operation endpoint is reachable (Operations.Get resolves the create op), and +// a non-DOCKER format + description round-trip instead of being dropped. +func TestSDKArtifactRegistryOperationAndFormat(t *testing.T) { + svc, _ := newARService(t) + ctx := context.Background() + + op, err := svc.Projects.Locations.Repositories.Create(testParent, &ar.Repository{ + Format: "MAVEN", + Description: "team maven repo", + }).RepositoryId("mvn").Context(ctx).Do() + if err != nil { + t.Fatalf("Create: %v", err) + } + + // Poll the operation the create returned — this hits the /operations/{op} + // route that previously 404'd. + polled, err := svc.Projects.Locations.Operations.Get(op.Name).Context(ctx).Do() + if err != nil { + t.Fatalf("Operations.Get (the #321 LRO route): %v", err) + } + + if !polled.Done { + t.Errorf("polled operation not done: %+v", polled) + } + + repo, err := svc.Projects.Locations.Repositories.Get(testParent + "/repositories/mvn").Context(ctx).Do() + if err != nil { + t.Fatalf("Get: %v", err) + } + + if repo.Format != "MAVEN" { + t.Errorf("format=%q want MAVEN (dropped on create)", repo.Format) + } + + if repo.Description != "team maven repo" { + t.Errorf("description=%q want 'team maven repo'", repo.Description) + } +} + func TestSDKArtifactRegistryDockerImages(t *testing.T) { svc, reg := newARService(t) ctx := context.Background() diff --git a/server/gcp/artifactregistry/types.go b/server/gcp/artifactregistry/types.go index 4061ca8e..4fdf4db8 100644 --- a/server/gcp/artifactregistry/types.go +++ b/server/gcp/artifactregistry/types.go @@ -47,7 +47,11 @@ type operationJSON struct { Response any `json:"response,omitempty"` } -const dockerFormat = "DOCKER" +const ( + dockerFormat = "DOCKER" + formatTag = "cloudemu:gcpArFormat" + descriptionTag = "cloudemu:gcpArDescription" +) func repositoryResourceName(project, location, id string) string { return "projects/" + project + "/locations/" + location + "/repositories/" + id @@ -69,15 +73,44 @@ func repoName(name string) string { } func toRepositoryJSON(project, location string, r *crdriver.Repository) repositoryJSON { + format := dockerFormat + if f := r.Tags[formatTag]; f != "" { + format = f + } + return repositoryJSON{ - Name: repositoryResourceName(project, location, repoName(r.Name)), - Format: dockerFormat, - Labels: r.Tags, - CreateTime: r.CreatedAt, - UpdateTime: r.CreatedAt, + Name: repositoryResourceName(project, location, repoName(r.Name)), + Format: format, + Description: r.Tags[descriptionTag], + Labels: stripReservedTags(r.Tags), + CreateTime: r.CreatedAt, + UpdateTime: r.CreatedAt, } } +// stripReservedTags returns user labels without cloudemu-internal keys. +func stripReservedTags(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + + out := make(map[string]string, len(in)) + + for k, v := range in { + if strings.HasPrefix(k, "cloudemu:") { + continue + } + + out[k] = v + } + + if len(out) == 0 { + return nil + } + + return out +} + func toDockerImageJSON(project, location, repo string, d *crdriver.ImageDetail) dockerImageJSON { base := repositoryResourceName(project, location, repo) + "/dockerImages/" + d.Digest diff --git a/server/gcp/cloudasset/filter.go b/server/gcp/cloudasset/filter.go index dbb31d5e..781ffcaa 100644 --- a/server/gcp/cloudasset/filter.go +++ b/server/gcp/cloudasset/filter.go @@ -36,6 +36,7 @@ const ( // (parsing assetType filters AND emitting type in response rows). const ( atComputeInstance = "compute.googleapis.com/Instance" + atComputeDisk = "compute.googleapis.com/Disk" atNetwork = "compute.googleapis.com/Network" atSubnetwork = "compute.googleapis.com/Subnetwork" atFirewall = "compute.googleapis.com/Firewall" @@ -238,6 +239,7 @@ type portableResourceType struct{ service, typ string } // gocyclo under the gate as the pairs grow. var gcpAssetToPortable = map[string]portableResourceType{ //nolint:gochecknoglobals // static lookup table atComputeInstance: {portableCompute, "Instance"}, + atComputeDisk: {portableCompute, "Volume"}, atNetwork: {portableNetworking, "VPC"}, atSubnetwork: {portableNetworking, "Subnet"}, atFirewall: {portableNetworking, "SecurityGroup"}, @@ -254,6 +256,7 @@ var gcpAssetToPortable = map[string]portableResourceType{ //nolint:gochecknoglob // portableToGCPAssetTypeMap is the inverse of gcpAssetToPortable. var portableToGCPAssetTypeMap = map[string]string{ //nolint:gochecknoglobals // static lookup table portableCompute + "/Instance": atComputeInstance, + portableCompute + "/Volume": atComputeDisk, portableNetworking + "/VPC": atNetwork, portableNetworking + "/Subnet": atSubnetwork, portableNetworking + "/SecurityGroup": atFirewall, diff --git a/server/gcp/cloudasset/handler.go b/server/gcp/cloudasset/handler.go index a40439f1..4a601606 100644 --- a/server/gcp/cloudasset/handler.go +++ b/server/gcp/cloudasset/handler.go @@ -2,6 +2,7 @@ package cloudasset import ( "context" + "encoding/base64" "encoding/json" "errors" "io" @@ -220,6 +221,7 @@ func (h *Handler) searchAllResources(w http.ResponseWriter, r *http.Request, _ s } pageSize := intParam(r, body, "pageSize") + assetTypes := searchAssetTypes(r, body) parsed := parseFilter(filter) if parsed.ForceEmpty { @@ -233,16 +235,74 @@ func (h *Handler) searchAllResources(w http.ResponseWriter, r *http.Request, _ s return } - if pageSize > 0 && pageSize < len(results) { - results = results[:pageSize] - } - out := make([]map[string]any, 0, len(results)) for i := range results { - out = append(out, resourceToSearchResult(&results[i], h.projectID)) + res := resourceToSearchResult(&results[i], h.projectID) + if !matchesAssetTypes(res["assetType"], assetTypes) { + continue + } + + out = append(out, res) + } + + // Offset-based pagination: pageToken carries the next start index. + start := decodePageToken(strParam(r, body, "pageToken")) + if start > len(out) { + start = len(out) + } + + page := out[start:] + + resp := map[string]any{} + + if pageSize > 0 && pageSize < len(page) { + page = page[:pageSize] + resp["nextPageToken"] = encodePageToken(start + pageSize) + } + + resp["results"] = page + + writeJSON(w, http.StatusOK, resp) +} + +// searchAssetTypes collects the assetTypes filter from repeated query params or +// a body array. +func searchAssetTypes(r *http.Request, body map[string]any) []string { + if v := r.URL.Query()["assetTypes"]; len(v) > 0 { + return v + } + + raw, ok := body["assetTypes"].([]any) + if !ok { + return nil + } + + out := make([]string, 0, len(raw)) + + for _, x := range raw { + if s, ok := x.(string); ok { + out = append(out, s) + } + } + + return out +} + +// matchesAssetTypes reports whether the result's assetType is in the filter +// (empty filter matches everything). +func matchesAssetTypes(assetType any, filter []string) bool { + if len(filter) == 0 { + return true } - writeJSON(w, http.StatusOK, map[string]any{"results": out}) + at, _ := assetType.(string) + for _, f := range filter { + if f == at { + return true + } + } + + return false } // ----- searchAllIamPolicies ----- @@ -394,19 +454,30 @@ func (h *Handler) listAssets(w http.ResponseWriter, r *http.Request, _ string) { return } - if pageSize > 0 && pageSize < len(allResults) { - allResults = allResults[:pageSize] + // Offset pagination: emit a nextPageToken when truncating so paged callers + // don't silently miss the remainder. + start := decodePageToken(r.URL.Query().Get("pageToken")) + if start > len(allResults) { + start = len(allResults) } - out := make([]map[string]any, 0, len(allResults)) - for i := range allResults { - out = append(out, resourceToAsset(&allResults[i])) + page := allResults[start:] + + resp := map[string]any{"readTime": nowRFC()} + + if pageSize > 0 && pageSize < len(page) { + page = page[:pageSize] + resp["nextPageToken"] = encodePageToken(start + pageSize) } - writeJSON(w, http.StatusOK, map[string]any{ - "assets": out, - "readTime": nowRFC(), - }) + out := make([]map[string]any, 0, len(page)) + for i := range page { + out = append(out, resourceToAsset(&page[i])) + } + + resp["assets"] = out + + writeJSON(w, http.StatusOK, resp) } // collectAssetsForTypes runs one engine query per assetType filter and @@ -627,6 +698,37 @@ func intParam(r *http.Request, body map[string]any, key string) int { return 0 } +func strParam(r *http.Request, body map[string]any, key string) string { + if v, ok := body[key].(string); ok && v != "" { + return v + } + + return r.URL.Query().Get(key) +} + +// encodePageToken/decodePageToken carry an offset as an opaque base64 token. +func encodePageToken(offset int) string { + return base64.StdEncoding.EncodeToString([]byte(strconv.Itoa(offset))) +} + +func decodePageToken(tok string) int { + if tok == "" { + return 0 + } + + b, err := base64.StdEncoding.DecodeString(tok) + if err != nil { + return 0 + } + + n, err := strconv.Atoi(string(b)) + if err != nil || n < 0 { + return 0 + } + + return n +} + func nowRFC() string { return time.Now().UTC().Format(time.RFC3339Nano) } diff --git a/server/gcp/clouddns/handler.go b/server/gcp/clouddns/handler.go index 8bbf115b..2e08b6b4 100644 --- a/server/gcp/clouddns/handler.go +++ b/server/gcp/clouddns/handler.go @@ -21,6 +21,7 @@ package clouddns import ( "net/http" "strings" + "sync/atomic" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" dnsdriver "github.com/stackshy/cloudemu/v2/services/dns/driver" @@ -42,7 +43,8 @@ const ( // Handler serves dns.googleapis.com v1 requests against a dns driver. type Handler struct { - dns dnsdriver.DNS + dns dnsdriver.DNS + changeSeq atomic.Uint64 } // New returns a Cloud DNS handler backed by d. diff --git a/server/gcp/clouddns/operations.go b/server/gcp/clouddns/operations.go index 57a0016d..00ec45f3 100644 --- a/server/gcp/clouddns/operations.go +++ b/server/gcp/clouddns/operations.go @@ -2,6 +2,7 @@ package clouddns import ( "net/http" + "strconv" cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" @@ -15,10 +16,20 @@ func (h *Handler) createZone(w http.ResponseWriter, r *http.Request, rt route) { return } + tags := req.Labels + if req.DNSName != "" { + tags = make(map[string]string, len(req.Labels)+1) + for k, v := range req.Labels { + tags[k] = v + } + + tags[dnsNameTag] = req.DNSName + } + info, err := h.dns.CreateZone(r.Context(), dnsdriver.ZoneConfig{ Name: req.Name, Private: privateFor(req.Visibility), - Tags: req.Labels, + Tags: tags, Scope: scope.Scope{Project: rt.project}, }) if err != nil { @@ -108,8 +119,21 @@ func (h *Handler) createChange(w http.ResponseWriter, r *http.Request, rt route) } } + // The canonical "update a record set" change deletes the old rrset and adds + // a new one with the SAME name+type in one batch. Such an addition is not a + // real conflict — it replaces a record this same change removes — so exempt + // additions whose (name,type) also appears in the deletions. + deleting := make(map[string]bool, len(req.Deletions)) + for i := range req.Deletions { + deleting[rrsetKey(req.Deletions[i].Name, req.Deletions[i].Type)] = true + } + for i := range req.Additions { a := &req.Additions[i] + if deleting[rrsetKey(a.Name, a.Type)] { + continue + } + if _, gerr := h.dns.GetRecord(r.Context(), id, a.Name, a.Type); gerr == nil { gcprest.WriteCErr(w, cerrors.Newf(cerrors.AlreadyExists, "record set %q %s already exists", a.Name, a.Type)) @@ -143,13 +167,18 @@ func (h *Handler) createChange(w http.ResponseWriter, r *http.Request, rt route) gcprest.WriteJSON(w, http.StatusOK, changeJSON{ Kind: kindChange, - ID: "1", + ID: strconv.FormatUint(h.changeSeq.Add(1), 10), Additions: req.Additions, Deletions: req.Deletions, Status: changeStatusDone, }) } +// rrsetKey identifies a record set by name+type within a zone. +func rrsetKey(name, rtype string) string { + return name + "|" + rtype +} + func (h *Handler) listRRSets(w http.ResponseWriter, r *http.Request, rt route) { id, err := h.resolveZoneID(r.Context(), rt.project, rt.zone) if err != nil { diff --git a/server/gcp/clouddns/sdk_roundtrip_test.go b/server/gcp/clouddns/sdk_roundtrip_test.go index 39e0c844..755ebbe0 100644 --- a/server/gcp/clouddns/sdk_roundtrip_test.go +++ b/server/gcp/clouddns/sdk_roundtrip_test.go @@ -167,6 +167,58 @@ func TestSDKCloudDNSRecordChanges(t *testing.T) { } } +// TestSDKCloudDNSUpdateRecord guards the #321 fix: the canonical record update +// (delete old rrset + add new one with the SAME name+type in one change) must +// succeed, not fail with AlreadyExists. It also asserts dnsName round-trips. +func TestSDKCloudDNSUpdateRecord(t *testing.T) { + svc := newDNSService(t) + ctx := context.Background() + + zone, err := svc.ManagedZones.Create(testProject, &dns.ManagedZone{ + Name: "upd-zone", + DnsName: "upd.example.com.", + }).Context(ctx).Do() + if err != nil { + t.Fatalf("ManagedZones.Create: %v", err) + } + + if zone.DnsName != "upd.example.com." { + t.Errorf("dnsName=%q want upd.example.com. (should not be the zone name)", zone.DnsName) + } + + old := &dns.ResourceRecordSet{Name: "www.upd.example.com.", Type: "A", Ttl: 300, Rrdatas: []string{"192.0.2.1"}} + + if _, err := svc.Changes.Create(testProject, "upd-zone", &dns.Change{ + Additions: []*dns.ResourceRecordSet{old}, + }).Context(ctx).Do(); err != nil { + t.Fatalf("Changes.Create(add): %v", err) + } + + // Update = delete old + add new, same name+type, one atomic change. + updated := &dns.ResourceRecordSet{Name: "www.upd.example.com.", Type: "A", Ttl: 600, Rrdatas: []string{"192.0.2.2"}} + + change, err := svc.Changes.Create(testProject, "upd-zone", &dns.Change{ + Deletions: []*dns.ResourceRecordSet{old}, + Additions: []*dns.ResourceRecordSet{updated}, + }).Context(ctx).Do() + if err != nil { + t.Fatalf("Changes.Create(delete+add same rrset) failed (the #321 bug): %v", err) + } + + if change.Id == "" || change.Id == "1" { + t.Errorf("change id=%q want a unique non-placeholder id", change.Id) + } + + rrsets, err := svc.ResourceRecordSets.List(testProject, "upd-zone").Context(ctx).Do() + if err != nil { + t.Fatalf("List: %v", err) + } + + if len(rrsets.Rrsets) != 1 || rrsets.Rrsets[0].Ttl != 600 || rrsets.Rrsets[0].Rrdatas[0] != "192.0.2.2" { + t.Fatalf("after update rrsets=%+v want single 600/192.0.2.2", rrsets.Rrsets) + } +} + func TestSDKCloudDNSErrors(t *testing.T) { svc := newDNSService(t) ctx := context.Background() diff --git a/server/gcp/clouddns/types.go b/server/gcp/clouddns/types.go index dba4dc4b..d3b12077 100644 --- a/server/gcp/clouddns/types.go +++ b/server/gcp/clouddns/types.go @@ -4,12 +4,17 @@ import ( "context" "hash/fnv" "strconv" + "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" dnsdriver "github.com/stackshy/cloudemu/v2/services/dns/driver" "github.com/stackshy/cloudemu/v2/services/scope" ) +// dnsNameTag stores a zone's DNS suffix (dnsName), which the dns driver does +// not model, so it round-trips through the zone's tags. +const dnsNameTag = "cloudemu:gcpDnsName" + // Kind values Cloud DNS stamps on its resources; the SDK tolerates them being // absent but real responses carry them, so we mirror the wire faithfully. const ( @@ -92,14 +97,45 @@ func numericID(id string) string { } func toManagedZoneJSON(info *dnsdriver.ZoneInfo) managedZoneJSON { + // dnsName is the DNS suffix (e.g. "example.com."), which the driver doesn't + // model, so it's stashed in a reserved tag at create. Fall back to the zone + // name only when absent. + dnsName := info.Name + if v, ok := info.Tags[dnsNameTag]; ok && v != "" { + dnsName = v + } + return managedZoneJSON{ Kind: kindManagedZone, Name: info.Name, ID: numericID(info.ID), Visibility: visibilityFor(info.Private), - Labels: info.Tags, - DNSName: info.Name, + Labels: stripReservedTags(info.Tags), + DNSName: dnsName, + } +} + +// stripReservedTags returns the user labels with cloudemu-internal keys removed. +func stripReservedTags(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + + out := make(map[string]string, len(in)) + + for k, v := range in { + if strings.HasPrefix(k, "cloudemu:") { + continue + } + + out[k] = v } + + if len(out) == 0 { + return nil + } + + return out } func toRecordSetJSON(rec *dnsdriver.RecordInfo) resourceRecordSetJSON { diff --git a/server/gcp/cloudfunctions/handler.go b/server/gcp/cloudfunctions/handler.go index 8d56472e..181b50e8 100644 --- a/server/gcp/cloudfunctions/handler.go +++ b/server/gcp/cloudfunctions/handler.go @@ -104,6 +104,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if parts.action == "generateUploadUrl" { + h.generateUploadURL(w, r, parts) + return + } + if parts.name != "" { h.serveResource(w, r, parts) return @@ -136,6 +141,22 @@ func (h *Handler) serveCollection(w http.ResponseWriter, r *http.Request, p func } } +// generateUploadURL answers functions:generateUploadUrl — the first step of a +// source-upload deploy. Real Cloud Functions returns a signed GCS URL the +// client PUTs the source zip to; the emulator returns a usable stub URL so the +// deploy flow proceeds. +func (*Handler) generateUploadURL(w http.ResponseWriter, r *http.Request, p functionPath) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed") + return + } + + url := "https://storage.googleapis.com/cloudemu-gcf-uploads/" + p.project + "/" + p.location + + "/source-" + strconv.FormatInt(time.Now().UnixNano(), 10) + ".zip" + + writeJSON(w, http.StatusOK, map[string]string{"uploadUrl": url}) +} + // serveOperation answers GET /v1/operations/{name}. We always return done=true // because mutations are synchronous in the mock; a poll is just an echo. func (*Handler) serveOperation(w http.ResponseWriter, r *http.Request) { @@ -401,6 +422,11 @@ func toCloudFunction(info *sdrv.FunctionInfo, p functionPath) cloudFunction { EnvVariables: info.Environment, UpdateTime: info.LastModified, VersionID: "1", + // Real Cloud Functions always advertises the HTTPS trigger URL; clients + // read it to invoke the function. + HTTPSTrigger: &httpsTrigger{ + URL: "https://" + scope.location + "-" + scope.project + ".cloudfunctions.net/" + scope.name, + }, } if info.Timeout > 0 { diff --git a/server/gcp/cloudfunctions/sdk_roundtrip_test.go b/server/gcp/cloudfunctions/sdk_roundtrip_test.go index 24d507ac..c399a79b 100644 --- a/server/gcp/cloudfunctions/sdk_roundtrip_test.go +++ b/server/gcp/cloudfunctions/sdk_roundtrip_test.go @@ -74,6 +74,22 @@ func TestSDKCloudFunctionsCreateGetListDelete(t *testing.T) { t.Fatalf("Name = %q, want suffix /functions/hello", got.Name) } + // The HTTPS trigger URL must be advertised (clients invoke via it). + if got.HttpsTrigger == nil || got.HttpsTrigger.Url == "" { + t.Fatalf("httpsTrigger.url missing: %+v", got.HttpsTrigger) + } + + // generateUploadUrl (first step of a source deploy) must return a URL. + up, err := svc.Projects.Locations.Functions.GenerateUploadUrl(parent, + &cloudfunctions.GenerateUploadUrlRequest{}).Context(ctx).Do() + if err != nil { + t.Fatalf("GenerateUploadUrl: %v", err) + } + + if up.UploadUrl == "" { + t.Fatal("GenerateUploadUrl returned no uploadUrl") + } + listResp, err := svc.Projects.Locations.Functions.List(parent).Context(ctx).Do() if err != nil { t.Fatalf("List: %v", err) diff --git a/server/gcp/cloudlogging/operations.go b/server/gcp/cloudlogging/operations.go index 5a749f1d..72b5ca65 100644 --- a/server/gcp/cloudlogging/operations.go +++ b/server/gcp/cloudlogging/operations.go @@ -2,13 +2,15 @@ package cloudlogging import ( "context" - "github.com/stackshy/cloudemu/v2/services/scope" "net/http" + "sort" + "strings" "time" cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" logdriver "github.com/stackshy/cloudemu/v2/services/logging/driver" + "github.com/stackshy/cloudemu/v2/services/scope" ) // writeEntries maps WriteLogEntries onto the driver. Cloud Logging creates a log @@ -43,7 +45,7 @@ func (h *Handler) writeEntries(w http.ResponseWriter, r *http.Request) { byLog[logID] = append(byLog[logID], logdriver.LogEvent{ Timestamp: parseTimestamp(e.Timestamp, now), - Message: e.TextPayload, + Message: encodeEntryPayload(e), }) } @@ -103,9 +105,24 @@ func (h *Handler) listEntries(w http.ResponseWriter, r *http.Request) { return } - out := make([]logEntryJSON, 0, len(events)) - for i := range events { - out = append(out, toLogEntryJSON(project, logID, &events[i])) + // Cloud Logging orders by timestamp — ascending by default, descending for + // "timestamp desc". Sort by the entry timestamp rather than assuming the + // driver's insertion order matches (out-of-order writes must still sort). + desc := strings.Contains(strings.ToLower(req.OrderBy), "desc") + + sorted := make([]logdriver.LogEvent, len(events)) + copy(sorted, events) + sort.SliceStable(sorted, func(i, j int) bool { + if desc { + return sorted[i].Timestamp.After(sorted[j].Timestamp) + } + + return sorted[i].Timestamp.Before(sorted[j].Timestamp) + }) + + out := make([]logEntryJSON, 0, len(sorted)) + for i := range sorted { + out = append(out, toLogEntryJSON(project, logID, &sorted[i])) } gcprest.WriteJSON(w, http.StatusOK, listLogEntriesResponse{Entries: out}) diff --git a/server/gcp/cloudlogging/sdk_roundtrip_test.go b/server/gcp/cloudlogging/sdk_roundtrip_test.go index aa0a39c5..b4da537f 100644 --- a/server/gcp/cloudlogging/sdk_roundtrip_test.go +++ b/server/gcp/cloudlogging/sdk_roundtrip_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "google.golang.org/api/googleapi" logging "google.golang.org/api/logging/v2" "google.golang.org/api/option" @@ -77,6 +78,60 @@ func TestSDKCloudLoggingWriteAndList(t *testing.T) { } } +// TestSDKCloudLoggingStructuredFields guards the #321 fix: severity, labels and +// jsonPayload round-trip through write→list, and orderBy "timestamp desc" is +// honored. +func TestSDKCloudLoggingStructuredFields(t *testing.T) { + svc := newLoggingService(t) + ctx := context.Background() + + logName := "projects/" + testProject + "/logs/structured" + base := time.Now().UTC().Truncate(time.Millisecond) + + // Write OUT of timestamp order (ERROR is later but written first) so the + // test guards a real timestamp sort, not a mere reversal of insertion order. + if _, err := svc.Entries.Write(&logging.WriteLogEntriesRequest{ + LogName: logName, + Entries: []*logging.LogEntry{ + { + Timestamp: base.Add(time.Second).Format(time.RFC3339Nano), + Severity: "ERROR", + Labels: map[string]string{"component": "api"}, + JsonPayload: googleapi.RawMessage(`{"code":500}`), + }, + {Timestamp: base.Format(time.RFC3339Nano), TextPayload: "first", Severity: "INFO"}, + }, + }).Context(ctx).Do(); err != nil { + t.Fatalf("Entries.Write: %v", err) + } + + resp, err := svc.Entries.List(&logging.ListLogEntriesRequest{ + ResourceNames: []string{"projects/" + testProject}, + Filter: `logName="` + logName + `"`, + OrderBy: "timestamp desc", + }).Context(ctx).Do() + if err != nil { + t.Fatalf("Entries.List: %v", err) + } + + if len(resp.Entries) != 2 { + t.Fatalf("got %d entries, want 2", len(resp.Entries)) + } + + // desc order: the ERROR entry (written second) comes first. + if resp.Entries[0].Severity != "ERROR" { + t.Errorf("first (desc) severity = %q, want ERROR", resp.Entries[0].Severity) + } + + if resp.Entries[0].Labels["component"] != "api" { + t.Errorf("labels did not round-trip: %v", resp.Entries[0].Labels) + } + + if len(resp.Entries[0].JsonPayload) == 0 { + t.Error("jsonPayload did not round-trip") + } +} + func TestSDKCloudLoggingLogsLifecycle(t *testing.T) { svc := newLoggingService(t) ctx := context.Background() diff --git a/server/gcp/cloudlogging/types.go b/server/gcp/cloudlogging/types.go index 6ea38566..ce19505e 100644 --- a/server/gcp/cloudlogging/types.go +++ b/server/gcp/cloudlogging/types.go @@ -1,6 +1,7 @@ package cloudlogging import ( + "encoding/json" "net/url" "strings" "time" @@ -8,15 +9,72 @@ import ( logdriver "github.com/stackshy/cloudemu/v2/services/logging/driver" ) -// logEntryJSON is the subset of the Cloud Logging LogEntry resource we model: -// a text payload plus a timestamp, keyed by logName. The driver has no notion -// of severity or structured payloads, so only textPayload round-trips. +// logEntryJSON is the subset of the Cloud Logging LogEntry resource we model. +// The driver stores only a message string, so the structured fields +// (severity, jsonPayload, labels, insertId) are JSON-enveloped into it on +// write and reconstructed on read — see encode/decodeEntryPayload. type logEntryJSON struct { - LogName string `json:"logName,omitempty"` - Timestamp string `json:"timestamp,omitempty"` - TextPayload string `json:"textPayload,omitempty"` - InsertID string `json:"insertId,omitempty"` - Severity string `json:"severity,omitempty"` + LogName string `json:"logName,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + TextPayload string `json:"textPayload,omitempty"` + JSONPayload map[string]any `json:"jsonPayload,omitempty"` + InsertID string `json:"insertId,omitempty"` + Severity string `json:"severity,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} + +// entryPayload is the JSON envelope stored in the driver's message string so a +// log entry's structured fields survive a write→read round-trip. +type entryPayload struct { + Text string `json:"t,omitempty"` + JSONPayload map[string]any `json:"j,omitempty"` + Severity string `json:"s,omitempty"` + InsertID string `json:"i,omitempty"` + Labels map[string]string `json:"l,omitempty"` +} + +// entryPayloadPrefix marks a driver message that carries an encoded envelope. +// A message without it is treated as a plain textPayload (backward compatible). +const entryPayloadPrefix = "\x00cloudemu-log\x00" + +func encodeEntryPayload(e *logEntryJSON) string { + // Plain text with no structured fields stays a bare string, so logs written + // by other means still read back naturally. + if e.Severity == "" && e.InsertID == "" && len(e.JSONPayload) == 0 && len(e.Labels) == 0 { + return e.TextPayload + } + + b, err := json.Marshal(entryPayload{ + Text: e.TextPayload, + JSONPayload: e.JSONPayload, + Severity: e.Severity, + InsertID: e.InsertID, + Labels: e.Labels, + }) + if err != nil { + return e.TextPayload + } + + return entryPayloadPrefix + string(b) +} + +func decodeEntryPayload(msg string, out *logEntryJSON) { + if !strings.HasPrefix(msg, entryPayloadPrefix) { + out.TextPayload = msg + return + } + + var p entryPayload + if err := json.Unmarshal([]byte(strings.TrimPrefix(msg, entryPayloadPrefix)), &p); err != nil { + out.TextPayload = msg + return + } + + out.TextPayload = p.Text + out.JSONPayload = p.JSONPayload + out.Severity = p.Severity + out.InsertID = p.InsertID + out.Labels = p.Labels } // writeLogEntriesRequest is the entries:write body. logName/resource may be set @@ -173,9 +231,12 @@ func parseTimestamp(ts string, now time.Time) time.Time { } func toLogEntryJSON(project, logID string, e *logdriver.LogEvent) logEntryJSON { - return logEntryJSON{ - LogName: logNameFor(project, logID), - Timestamp: e.Timestamp.UTC().Format(time.RFC3339Nano), - TextPayload: e.Message, + out := logEntryJSON{ + LogName: logNameFor(project, logID), + Timestamp: e.Timestamp.UTC().Format(time.RFC3339Nano), } + + decodeEntryPayload(e.Message, &out) + + return out } diff --git a/server/gcp/cloudsql/operations.go b/server/gcp/cloudsql/operations.go index 2f38e845..b889a456 100644 --- a/server/gcp/cloudsql/operations.go +++ b/server/gcp/cloudsql/operations.go @@ -204,7 +204,14 @@ func (h *Handler) listBackupRuns(w http.ResponseWriter, r *http.Request, p *sqlP func (h *Handler) getBackupRun(w http.ResponseWriter, r *http.Request, p *sqlPath) { snaps, err := h.db.DescribeSnapshots(r.Context(), []string{p.subName}, p.name) - if err != nil || len(snaps) == 0 { + if err != nil { + // Surface the real backend error rather than masking every failure as + // NOT_FOUND (which would send callers debugging the wrong subsystem). + writeErr(w, err) + return + } + + if len(snaps) == 0 { writeError(w, http.StatusNotFound, "NOT_FOUND", "backup run "+p.subName+" not found") return } diff --git a/server/gcp/compute/handler.go b/server/gcp/compute/handler.go index 35aae8af..830eabfc 100644 --- a/server/gcp/compute/handler.go +++ b/server/gcp/compute/handler.go @@ -230,7 +230,16 @@ func serveOperations(w http.ResponseWriter, r *http.Request, rp gcprest.Resource } if rp.ResourceName == "" { - writeNotImplemented(w, "operations list") + // The mock runs synchronously and retains no pending operations, so a + // list is legitimately empty rather than unimplemented. + host := hostFromRequest(r) + gcprest.WriteJSON(w, http.StatusOK, map[string]any{ + "kind": "compute#operationList", + "id": "projects/" + rp.Project + "/operations", + "items": []any{}, + "selfLink": gcprest.SelfLink(host, rp.Project, rp.Scope, rp.ScopeName, "operations", ""), + }) + return } diff --git a/server/gcp/compute/images.go b/server/gcp/compute/images.go index e59dad1e..b0eecd6b 100644 --- a/server/gcp/compute/images.go +++ b/server/gcp/compute/images.go @@ -54,31 +54,13 @@ func (h *Handler) insertImage(w http.ResponseWriter, r *http.Request, rp gcprest return } - // GCP images can be created from a disk, snapshot, or imported. The - // driver's CreateImage takes an InstanceID — we fake one from any - // existing instance just so the driver lets the create succeed. - insts, err := h.compute.DescribeInstances(r.Context(), nil, nil) - if err != nil { - gcprest.WriteCErr(w, err) - return - } - - instanceID := "" - if len(insts) > 0 { - instanceID = insts[0].ID - } - - if instanceID == "" { - gcprest.WriteError(w, http.StatusBadRequest, "invalid", - "images mock requires at least one running instance to derive the image from") - - return - } - + // GCP images are created from a disk, snapshot, or import — never from a + // source instance (that is EC2's model). Pass an empty InstanceID so the + // driver takes the source-based path; record the source in the description + // so a read reflects what it was built from. cfg := computedriver.ImageConfig{ - InstanceID: instanceID, Name: req.Name, - Description: req.Name, + Description: imageSourceDescription(&req), Tags: mergeImageTags(req.Labels, req.Name), } @@ -148,6 +130,19 @@ func (h *Handler) deleteImage(w http.ResponseWriter, r *http.Request, rp gcprest gcprest.WriteJSON(w, http.StatusOK, op) } +// imageSourceDescription records the source the image was built from so a read +// reflects it. Falls back to the image name when no source was given (import). +func imageSourceDescription(req *imageRequest) string { + switch { + case req.SourceDisk != "": + return "sourceDisk: " + req.SourceDisk + case req.SourceSnapshot != "": + return "sourceSnapshot: " + req.SourceSnapshot + default: + return req.Name + } +} + func findImageByName(ctx context.Context, c computedriver.Compute, name string) (*computedriver.ImageInfo, error) { imgs, err := c.DescribeImages(ctx, nil) if err != nil { diff --git a/server/gcp/compute/instances.go b/server/gcp/compute/instances.go index 121b7ea0..4e7d5ae9 100644 --- a/server/gcp/compute/instances.go +++ b/server/gcp/compute/instances.go @@ -115,7 +115,15 @@ func (h *Handler) deleteInstance(w http.ResponseWriter, r *http.Request, rp gcpr return } - if err := h.compute.TerminateInstances(r.Context(), []string{inst.ID}); err != nil { + // GCP instances.delete removes the resource (a subsequent GET is 404), + // unlike EC2 terminate which leaves a TERMINATED tombstone. Hard-remove + // when the driver supports it; fall back to terminate otherwise. + if remover, ok := h.compute.(instanceRemover); ok { + if err := remover.RemoveInstance(r.Context(), inst.ID); err != nil { + gcprest.WriteCErr(w, err) + return + } + } else if err := h.compute.TerminateInstances(r.Context(), []string{inst.ID}); err != nil { gcprest.WriteCErr(w, err) return } @@ -171,6 +179,12 @@ func (h *Handler) action( gcprest.WriteJSON(w, http.StatusOK, doneOp) } +// instanceRemover is the GCP-local hard-delete capability (removes the +// instance rather than tombstoning it). The GCE provider Mock implements it. +type instanceRemover interface { + RemoveInstance(ctx context.Context, instanceID string) error +} + // findByName looks up an instance by its GCP-tagged name. func findByName(ctx context.Context, c computedriver.Compute, name string) (*computedriver.Instance, error) { instances, err := c.DescribeInstances(ctx, nil, nil) @@ -252,15 +266,33 @@ func toInstanceResponse(inst *computedriver.Instance, rp gcprest.ResourcePath, h name := tagOr(inst.Tags, gcpNameTag, rp.ResourceName) return instanceResponse{ - Kind: "compute#instance", - ID: numericID(inst.ID), - Name: name, - MachineType: gcprest.SelfLink(host, rp.Project, rp.Scope, rp.ScopeName, "machineTypes", inst.InstanceType), - Status: gcpStatusFor(inst.State), - Zone: host + "/compute/v1/projects/" + rp.Project + "/zones/" + rp.ScopeName, - SelfLink: gcprest.SelfLink(host, rp.Project, rp.Scope, rp.ScopeName, "instances", name), - Labels: stripInternalTags(inst.Tags), + Kind: "compute#instance", + ID: numericID(inst.ID), + Name: name, + MachineType: gcprest.SelfLink(host, rp.Project, rp.Scope, rp.ScopeName, "machineTypes", inst.InstanceType), + Status: gcpStatusFor(inst.State), + Zone: host + "/compute/v1/projects/" + rp.Project + "/zones/" + rp.ScopeName, + SelfLink: gcprest.SelfLink(host, rp.Project, rp.Scope, rp.ScopeName, "instances", name), + NetworkInterfaces: instanceNICs(inst), + Labels: stripInternalTags(inst.Tags), + } +} + +// instanceNICs echoes back the network interface the instance was created +// with. The driver stores the subnetwork the client set plus the private IP it +// assigned; a read must return them (real GCP always reports a NIC), otherwise +// a client that sets a subnet reads back an empty interface list. +func instanceNICs(inst *computedriver.Instance) []networkInterface { + if inst.SubnetID == "" && inst.PrivateIP == "" && inst.VPCID == "" { + return nil } + + return []networkInterface{{ + Name: "nic0", + Network: inst.VPCID, + Subnetwork: inst.SubnetID, + NetworkIP: inst.PrivateIP, + }} } // numericID returns a stable uint64-shaped string derived from a driver diff --git a/server/gcp/compute/sdk_roundtrip_test.go b/server/gcp/compute/sdk_roundtrip_test.go index b28865ea..d7be3679 100644 --- a/server/gcp/compute/sdk_roundtrip_test.go +++ b/server/gcp/compute/sdk_roundtrip_test.go @@ -158,6 +158,96 @@ func TestSDKGCEInstanceRoundTrip(t *testing.T) { } } +// TestSDKGCEInstanceNICRoundTrip guards the #321 fix: an instance read must +// echo the network interface it was created with (subnetwork + assigned +// networkIP), not an empty list. +func TestSDKGCEInstanceNICRoundTrip(t *testing.T) { + cloudP := cloudemu.NewGCP() + srv := gcpserver.New(gcpserver.Drivers{Compute: cloudP.GCE}) + + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + + client := newSDKInstancesClient(t, ts) + ctx := context.Background() + + insertOp, err := client.Insert(ctx, &computepb.InsertInstanceRequest{ + Project: testProject, Zone: testZone, + InstanceResource: &computepb.Instance{ + Name: ptrStr("nic-vm"), + MachineType: ptrStr("zones/" + testZone + "/machineTypes/n1-standard-1"), + NetworkInterfaces: []*computepb.NetworkInterface{ + {Subnetwork: ptrStr("regions/us-central1/subnetworks/my-subnet")}, + }, + }, + }) + if err != nil { + t.Fatalf("Insert: %v", err) + } + + if err := insertOp.Wait(ctx); err != nil { + t.Fatalf("Insert wait: %v", err) + } + + got, err := client.Get(ctx, &computepb.GetInstanceRequest{ + Project: testProject, Zone: testZone, Instance: "nic-vm", + }) + if err != nil { + t.Fatalf("Get: %v", err) + } + + nics := got.GetNetworkInterfaces() + if len(nics) == 0 { + t.Fatal("read-back instance has no networkInterfaces") + } + + if !strings.HasSuffix(nics[0].GetSubnetwork(), "subnetworks/my-subnet") { + t.Errorf("subnetwork=%q want ...subnetworks/my-subnet", nics[0].GetSubnetwork()) + } + + if nics[0].GetNetworkIP() == "" { + t.Error("networkIP is empty; the mock assigns a private IP on create") + } +} + +// TestSDKGCEImageFromScratch guards the #321 fix: an image create must not +// require a pre-existing instance (GCP images come from disks/snapshots, not +// instances). This creates an image with no instances present. +func TestSDKGCEImageFromScratch(t *testing.T) { + cloudP := cloudemu.NewGCP() + srv := gcpserver.New(gcpserver.Drivers{Compute: cloudP.GCE}) + + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + + ctx := context.Background() + imgClient := newImagesSDKClient(t, ts) + + op, err := imgClient.Insert(ctx, &computepb.InsertImageRequest{ + Project: testProject, + ImageResource: &computepb.Image{ + Name: ptrStr("disk-img"), + SourceDisk: ptrStr("zones/" + testZone + "/disks/my-disk"), + }, + }) + if err != nil { + t.Fatalf("Insert: %v", err) + } + + if err := op.Wait(ctx); err != nil { + t.Fatalf("Insert wait (image-from-disk should not need an instance): %v", err) + } + + got, err := imgClient.Get(ctx, &computepb.GetImageRequest{Project: testProject, Image: "disk-img"}) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.GetName() != "disk-img" { + t.Errorf("name=%q want disk-img", got.GetName()) + } +} + // ptr helpers — computepb fields are pointers because the protocol uses // proto3-with-presence and the SDK marshalers care about the distinction // between unset and zero-value. diff --git a/server/gcp/compute/types.go b/server/gcp/compute/types.go index 90be1580..ee06de71 100644 --- a/server/gcp/compute/types.go +++ b/server/gcp/compute/types.go @@ -29,8 +29,10 @@ type diskInitializeParams struct { } type networkInterface struct { + Name string `json:"name,omitempty"` Network string `json:"network,omitempty"` Subnetwork string `json:"subnetwork,omitempty"` + NetworkIP string `json:"networkIP,omitempty"` } type tagsBlock struct { diff --git a/server/gcp/eventarc/gapic_lro_test.go b/server/gcp/eventarc/gapic_lro_test.go new file mode 100644 index 00000000..e9c5e942 --- /dev/null +++ b/server/gcp/eventarc/gapic_lro_test.go @@ -0,0 +1,65 @@ +package eventarc_test + +import ( + "context" + "net/http/httptest" + "testing" + + eventarc "cloud.google.com/go/eventarc/apiv1" + "cloud.google.com/go/eventarc/apiv1/eventarcpb" + "google.golang.org/api/option" + + "github.com/stackshy/cloudemu/v2" + gcpserver "github.com/stackshy/cloudemu/v2/server/gcp" +) + +// TestGAPICCreateTriggerWait is the review's #3 check for eventarc: the finding +// targeted the apiv1 GAPIC client's LRO .Wait(), which the raw REST client +// never exercised. CreateTrigger(...).Wait() must resolve (not 404, not a +// missing-@type decode error) and return the created trigger. +func TestGAPICCreateTriggerWait(t *testing.T) { + cloud := cloudemu.NewGCP() + srv := gcpserver.New(gcpserver.DriversFrom(cloud)) + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + + ctx := context.Background() + + client, err := eventarc.NewRESTClient(ctx, + option.WithEndpoint(ts.URL), + option.WithoutAuthentication(), + option.WithHTTPClient(ts.Client()), + ) + if err != nil { + t.Fatalf("NewRESTClient: %v", err) + } + + t.Cleanup(func() { _ = client.Close() }) + + op, err := client.CreateTrigger(ctx, &eventarcpb.CreateTriggerRequest{ + Parent: "projects/demo/locations/us-central1", + TriggerId: "gapic-trig", + Trigger: &eventarcpb.Trigger{ + EventFilters: []*eventarcpb.EventFilter{ + {Attribute: "type", Value: "google.cloud.pubsub.topic.v1.messagePublished"}, + }, + Destination: &eventarcpb.Destination{ + Descriptor_: &eventarcpb.Destination_CloudRun{ + CloudRun: &eventarcpb.CloudRun{Service: "svc", Region: "us-central1"}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("CreateTrigger: %v", err) + } + + trig, err := op.Wait(ctx) + if err != nil { + t.Fatalf("op.Wait (the #3 GAPIC LRO fix): %v", err) + } + + if trig == nil || trig.GetName() == "" { + t.Fatalf("Wait returned no trigger: %+v", trig) + } +} diff --git a/server/gcp/eventarc/handler.go b/server/gcp/eventarc/handler.go index 3bb99949..12298304 100644 --- a/server/gcp/eventarc/handler.go +++ b/server/gcp/eventarc/handler.go @@ -14,7 +14,7 @@ // // - Auto-provisioning one event bus per location, named "eventarc-", // the first time a trigger is created there. This is a synthesized -// container with no Eventarc analogue — the SDK never sees it. +// container with no Eventarc analog — the SDK never sees it. // - Mapping each trigger onto a driver rule keyed by the trigger id, with the // trigger's eventFilters serialized into the rule's EventPattern and the // destination folded into a single target so Get/List can round-trip them. @@ -47,9 +47,10 @@ import ( ) const ( - pathPrefix = "/v1/projects/" - locationsSeg = "locations" - triggersSeg = "triggers" + pathPrefix = "/v1/projects/" + locationsSeg = "locations" + triggersSeg = "triggers" + operationsSeg = "operations" ) // minTriggersCollectionParts is the segment count of a triggers collection @@ -68,9 +69,10 @@ func New(b ebdriver.EventBus) *Handler { } type route struct { - project string - location string - trigger string // trigger id; empty for the collection + project string + location string + trigger string // trigger id; empty for the collection + operation string // operation id for an /operations/{op} path } // parseRoute extracts the components of an Eventarc v1 triggers path. @@ -80,14 +82,27 @@ func parseRoute(urlPath string) (route, bool) { } parts := strings.Split(strings.TrimPrefix(urlPath, "/v1/"), "/") - // parts: [projects, {p}, locations, {l}, triggers, {id}?] + // parts: [projects, {p}, locations, {l}, {triggers|operations}, {id}?] if len(parts) < minTriggersCollectionParts || - parts[0] != "projects" || parts[2] != locationsSeg || parts[4] != triggersSeg { + parts[0] != "projects" || parts[2] != locationsSeg { return route{}, false } rt := route{project: parts[1], location: parts[3]} + // LRO polling: GAPIC .Wait() GETs the operation the create/delete returned. + if parts[4] == operationsSeg { + if len(parts) > minTriggersCollectionParts { + rt.operation = parts[5] + } + + return rt, true + } + + if parts[4] != triggersSeg { + return route{}, false + } + if len(parts) > minTriggersCollectionParts { rt.trigger = parts[5] } @@ -110,6 +125,17 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if rt.operation != "" { + // Operations complete synchronously; any poll resolves to done, which + // unblocks GAPIC .Wait() callers instead of 404ing. + gcprest.WriteJSON(w, http.StatusOK, operationJSON{ + Name: "projects/" + rt.project + "/locations/" + rt.location + "/operations/" + rt.operation, + Done: true, + }) + + return + } + if rt.trigger == "" { switch r.Method { case http.MethodGet: diff --git a/server/gcp/eventarc/operations.go b/server/gcp/eventarc/operations.go index 10a681af..1b8600f2 100644 --- a/server/gcp/eventarc/operations.go +++ b/server/gcp/eventarc/operations.go @@ -1,6 +1,7 @@ package eventarc import ( + "encoding/json" "net/http" cerrors "github.com/stackshy/cloudemu/v2/errors" @@ -39,6 +40,7 @@ func (h *Handler) createTrigger(w http.ResponseWriter, r *http.Request, rt *rout Name: triggerID, EventBus: bus, EventPattern: encodeEventPattern(body.EventFilters), + Description: encodeTriggerMeta(body.ServiceAccount, body.Labels), }); err != nil { gcprest.WriteCErr(w, err) return @@ -62,7 +64,29 @@ func (h *Handler) createTrigger(w http.ResponseWriter, r *http.Request, rt *rout } gcprest.WriteJSON(w, http.StatusOK, doneOperation(rt, triggerID, - toTriggerJSON(rt.project, rt.location, stored))) + typedResponse(triggerTypeURL, toTriggerJSON(rt.project, rt.location, stored)))) +} + +// triggerTypeURL is the protobuf Any type URL a GAPIC eventarc client expects +// in a done LRO's response so CreateTriggerOperation.Wait() can decode it. +const triggerTypeURL = "type.googleapis.com/google.cloud.eventarc.v1.Trigger" + +// typedResponse renders v as a google.protobuf.Any JSON object (resource fields +// + "@type"); a GAPIC .Wait() can't unmarshal the response without @type. +func typedResponse(typeURL string, v any) map[string]any { + b, err := json.Marshal(v) + if err != nil { + return nil + } + + m := map[string]any{} + if err := json.Unmarshal(b, &m); err != nil { + return nil + } + + m["@type"] = typeURL + + return m } func (h *Handler) getTrigger(w http.ResponseWriter, r *http.Request, rt *route) { diff --git a/server/gcp/eventarc/sdk_roundtrip_test.go b/server/gcp/eventarc/sdk_roundtrip_test.go index 86ec5aaa..1452c7b2 100644 --- a/server/gcp/eventarc/sdk_roundtrip_test.go +++ b/server/gcp/eventarc/sdk_roundtrip_test.go @@ -43,6 +43,51 @@ func parent() string { return "projects/" + testProject + "/locations/" + testLocation } +// TestSDKEventarcOperationAndMetadata guards two #321 fixes: the LRO operation +// endpoint resolves (Operations.Get), and serviceAccount + labels round-trip +// on the trigger instead of being dropped. +func TestSDKEventarcOperationAndMetadata(t *testing.T) { + svc := newEventarcService(t) + ctx := context.Background() + + trigger := &eventarc.Trigger{ + EventFilters: []*eventarc.EventFilter{ + {Attribute: "type", Value: "google.cloud.pubsub.topic.v1.messagePublished"}, + }, + Destination: &eventarc.Destination{CloudRun: &eventarc.CloudRun{Service: "svc", Region: testLocation}}, + ServiceAccount: "runner@demo.iam.gserviceaccount.com", + Labels: map[string]string{"env": "prod"}, + } + + op, err := svc.Projects.Locations.Triggers.Create(parent(), trigger). + TriggerId("meta-trig").Context(ctx).Do() + if err != nil { + t.Fatalf("Triggers.Create: %v", err) + } + + polled, err := svc.Projects.Locations.Operations.Get(op.Name).Context(ctx).Do() + if err != nil { + t.Fatalf("Operations.Get (the #321 LRO route): %v", err) + } + + if !polled.Done { + t.Errorf("polled operation not done: %+v", polled) + } + + got, err := svc.Projects.Locations.Triggers.Get(parent() + "/triggers/meta-trig").Context(ctx).Do() + if err != nil { + t.Fatalf("Triggers.Get: %v", err) + } + + if got.ServiceAccount != "runner@demo.iam.gserviceaccount.com" { + t.Errorf("serviceAccount=%q dropped", got.ServiceAccount) + } + + if got.Labels["env"] != "prod" { + t.Errorf("labels=%v want env=prod", got.Labels) + } +} + func TestSDKEventarcTriggerLifecycle(t *testing.T) { svc := newEventarcService(t) ctx := context.Background() diff --git a/server/gcp/eventarc/types.go b/server/gcp/eventarc/types.go index b8154595..c696692d 100644 --- a/server/gcp/eventarc/types.go +++ b/server/gcp/eventarc/types.go @@ -65,7 +65,7 @@ func triggerResourceName(project, location, id string) string { } // channelName is the synthesized event-bus name backing a location's triggers. -// It has no Eventarc analogue and is never surfaced to the SDK. +// It has no Eventarc analog and is never surfaced to the SDK. func channelName(location string) string { return "eventarc-" + location } @@ -156,11 +156,48 @@ func destinationSummary(dest *destinationJSON) string { // toTriggerJSON converts a driver rule into its Eventarc Trigger element. func toTriggerJSON(project, location string, rule *ebdriver.Rule) triggerJSON { + sa, labels := decodeTriggerMeta(rule.Description) + return triggerJSON{ - Name: triggerResourceName(project, location, rule.Name), - EventFilters: decodeEventPattern(rule.EventPattern), - Destination: destinationFromTargets(rule.Targets), - CreateTime: rule.CreatedAt, - UpdateTime: rule.CreatedAt, + Name: triggerResourceName(project, location, rule.Name), + EventFilters: decodeEventPattern(rule.EventPattern), + Destination: destinationFromTargets(rule.Targets), + ServiceAccount: sa, + Labels: labels, + CreateTime: rule.CreatedAt, + UpdateTime: rule.CreatedAt, } } + +// triggerMeta holds the Eventarc fields the eventbus Rule can't store natively; +// it is JSON-encoded into the rule's Description. +type triggerMeta struct { + ServiceAccount string `json:"serviceAccount,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} + +func encodeTriggerMeta(sa string, labels map[string]string) string { + if sa == "" && len(labels) == 0 { + return "" + } + + b, err := json.Marshal(triggerMeta{ServiceAccount: sa, Labels: labels}) + if err != nil { + return "" + } + + return string(b) +} + +func decodeTriggerMeta(s string) (serviceAccount string, labels map[string]string) { + if s == "" { + return "", nil + } + + var m triggerMeta + if err := json.Unmarshal([]byte(s), &m); err != nil { + return "", nil + } + + return m.ServiceAccount, m.Labels +} diff --git a/server/gcp/fcm/operations.go b/server/gcp/fcm/operations.go index 361006ae..13196463 100644 --- a/server/gcp/fcm/operations.go +++ b/server/gcp/fcm/operations.go @@ -50,6 +50,23 @@ func (h *Handler) sendMessage(w http.ResponseWriter, r *http.Request, rt route) return } + // FCM requires exactly one target — token, topic, or condition. Reject a + // message that sets more than one (real FCM returns INVALID_ARGUMENT). + targets := 0 + + for _, t := range []string{body.Message.Token, body.Message.Topic, body.Message.Condition} { + if t != "" { + targets++ + } + } + + if targets > 1 { + gcprest.WriteError(w, http.StatusBadRequest, "invalid", + "exactly one of token, topic, condition may be set") + + return + } + if body.ValidateOnly { // Dry run: validate the request only — do NOT auto-create the topic, // publish, or emit metrics. Real FCM returns a fabricated message name. diff --git a/server/gcp/fcm/sdk_roundtrip_test.go b/server/gcp/fcm/sdk_roundtrip_test.go index 3daa71d7..9ecc943f 100644 --- a/server/gcp/fcm/sdk_roundtrip_test.go +++ b/server/gcp/fcm/sdk_roundtrip_test.go @@ -92,4 +92,14 @@ func TestSDKFCMSendErrors(t *testing.T) { if !errors.As(err, &gerr) || gerr.Code != 400 { t.Fatalf("Send(empty): got %v, want 400", err) } + + // A message with more than one target (topic + token) is INVALID_ARGUMENT + // — real FCM allows exactly one of token/topic/condition. + _, err = svc.Projects.Messages.Send("projects/"+testProject, &fcm.SendMessageRequest{ + Message: &fcm.Message{Topic: "news", Token: "device-tok"}, + }).Context(ctx).Do() + + if !errors.As(err, &gerr) || gerr.Code != 400 { + t.Fatalf("Send(multi-target): got %v, want 400", err) + } } diff --git a/server/gcp/firestore/firestore_lifecycle_test.go b/server/gcp/firestore/firestore_lifecycle_test.go index 802bd5ad..7aa237b4 100644 --- a/server/gcp/firestore/firestore_lifecycle_test.go +++ b/server/gcp/firestore/firestore_lifecycle_test.go @@ -292,23 +292,22 @@ func TestDatabaseTypedErrors(t *testing.T) { t.Error("missing doc snapshot should report Exists()==false") } - // Missing collection: never created as a driver table. + // Reading a document in a collection that has never been written is + // NotFound (a read does not create the collection). _, err = client.Collection("ghost").Doc("x").Get(ctx) if code := dbSDKCode(err); code != codes.NotFound { t.Errorf("Get in missing collection: code=%v err=%v, want NotFound", code, err) } - // Writing into a missing collection is also NotFound (tables must be - // pre-created — emulator-specific behavior per survey). - _, err = client.Collection("ghost").Doc("x").Set(ctx, map[string]any{"a": 1}) - if code := dbSDKCode(err); code != codes.NotFound { - t.Errorf("Set in missing collection: code=%v err=%v, want NotFound", code, err) + // Writing into a not-yet-existent collection succeeds — real Firestore + // creates the collection lazily on first write (#321 E2E fix). + if _, err = client.Collection("ghost").Doc("x").Set(ctx, map[string]any{"a": 1}); err != nil { + t.Errorf("Set in new collection: %v, want nil (lazy create)", err) } - // Listing a missing collection surfaces NotFound through the iterator. - _, err = client.Collection("ghost").Documents(ctx).Next() - if code := dbSDKCode(err); code != codes.NotFound { - t.Errorf("List missing collection: code=%v err=%v, want NotFound", code, err) + // After that write the document is readable. + if _, err = client.Collection("ghost").Doc("x").Get(ctx); err != nil { + t.Errorf("Get after lazy-create Set: %v, want nil", err) } // Deleting a missing document is idempotent — no error (matches real diff --git a/server/gcp/firestore/handler.go b/server/gcp/firestore/handler.go index 1cbb0e15..bb0ed8d4 100644 --- a/server/gcp/firestore/handler.go +++ b/server/gcp/firestore/handler.go @@ -13,6 +13,7 @@ package firestore import ( + "context" "encoding/json" "fmt" "net/http" @@ -237,6 +238,8 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, _ string) { } } + h.ensureCollection(r.Context(), p.collection) + if perr := h.db.PutItem(r.Context(), p.collection, item); perr != nil { writeErr(w, perr) return @@ -588,7 +591,9 @@ func parseFirestorePath(path string) (firestorePath, error) { func (h *Handler) createDocument(w http.ResponseWriter, r *http.Request, p firestorePath) { docID := r.URL.Query().Get("documentId") - if docID == "" { + + explicitID := docID != "" + if !explicitID { // Auto-generate an ID; Firestore's default IDs are 20-char IDs but // any string is fine for our purposes. docID = "auto-" + strconv.FormatInt(time.Now().UnixNano(), 10) @@ -600,9 +605,24 @@ func (h *Handler) createDocument(w http.ResponseWriter, r *http.Request, p fires return } + // CreateDocument with an explicit id must fail if that id already exists, + // rather than silently overwriting (real Firestore returns ALREADY_EXISTS). + if explicitID { + if _, err := h.db.GetItem(r.Context(), p.collection, map[string]any{"id": docID}); err == nil { + writeError(w, http.StatusConflict, "ALREADY_EXISTS", + "document "+docID+" already exists") + + return + } + } + item := fieldsToMap(inDoc.Fields) item["id"] = docID + // Firestore creates a collection lazily on first write; the driver requires + // the "table" to exist, so ensure it before writing. + h.ensureCollection(r.Context(), p.collection) + if err := h.db.PutItem(r.Context(), p.collection, item); err != nil { writeErr(w, err) return @@ -611,6 +631,13 @@ func (h *Handler) createDocument(w http.ResponseWriter, r *http.Request, p fires writeJSON(w, http.StatusOK, mapToDocument(item, p, docID)) } +// ensureCollection lazily creates a Firestore collection (driver table keyed on +// the document "id") so a first write doesn't fail with "collection not found". +// An already-exists result is benign. +func (h *Handler) ensureCollection(ctx context.Context, collection string) { + _ = h.db.CreateTable(ctx, dbdriver.TableConfig{Name: collection, PartitionKey: "id"}) +} + func (h *Handler) getDocument(w http.ResponseWriter, r *http.Request, p firestorePath) { item, err := h.db.GetItem(r.Context(), p.collection, map[string]any{"id": p.documentID}) if err != nil { diff --git a/server/gcp/fullserver_test.go b/server/gcp/fullserver_test.go new file mode 100644 index 00000000..c0912d6a --- /dev/null +++ b/server/gcp/fullserver_test.go @@ -0,0 +1,146 @@ +package gcp_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2" + gcpserver "github.com/stackshy/cloudemu/v2/server/gcp" +) + +// fullServer boots the complete GCP server with EVERY handler registered (as +// the `cloudemu serve --providers gcp` binary does), so cross-handler dispatch +// collisions surface — the kind single-driver package tests can't catch. +func fullServer(t *testing.T) *httptest.Server { + t.Helper() + + srv := gcpserver.New(gcpserver.DriversFrom(cloudemu.NewGCP())) + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + + return ts +} + +func do(t *testing.T, ts *httptest.Server, method, path, body string) (int, string) { + t.Helper() + + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + + req, err := http.NewRequest(method, ts.URL+path, rdr) + if err != nil { + t.Fatalf("new request: %v", err) + } + + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer resp.Body.Close() + + b, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, string(b) +} + +// TestFullServerLROOperationPolling guards the #321 E2E fix: in the full server +// (alloydb/gke register before artifactregistry/eventarc/memorystore and used +// to shadow location operations), a shared LRO handler must resolve every +// location-scoped operation poll to done, not 404. +func TestFullServerLROOperationPolling(t *testing.T) { + ts := fullServer(t) + + // artifactregistry: create returns an op named .../operations/op-r1. + if code, _ := do(t, ts, http.MethodPost, + "/v1/projects/demo/locations/us/repositories?repositoryId=r1", `{"format":"MAVEN"}`); code != http.StatusOK { + t.Fatalf("AR create: %d", code) + } + + if code, body := do(t, ts, http.MethodGet, + "/v1/projects/demo/locations/us/operations/op-r1", ""); code != http.StatusOK || !strings.Contains(body, `"done":true`) { + t.Fatalf("AR op poll: code=%d body=%s (want 200 done:true)", code, body) + } + + // eventarc. + do(t, ts, http.MethodPost, + "/v1/projects/demo/locations/us-central1/triggers?triggerId=t1", + `{"eventFilters":[{"attribute":"type","value":"x"}],"destination":{"cloudRun":{"service":"s","region":"us-central1"}}}`) + + if code, body := do(t, ts, http.MethodGet, + "/v1/projects/demo/locations/us-central1/operations/op-t1", ""); code != http.StatusOK || !strings.Contains(body, `"done":true`) { + t.Fatalf("eventarc op poll: code=%d body=%s", code, body) + } + + // gke (a legitimate location-operations owner) must still resolve. + do(t, ts, http.MethodPost, "/v1/projects/demo/locations/us-central1/clusters", + `{"cluster":{"name":"k1","initialNodeCount":1}}`) + + // GKE's container.Operation uses a `status` field, not the longrunning + // `done` bool — the shared handler must satisfy both. + if code, body := do(t, ts, http.MethodGet, + "/v1/projects/demo/locations/us-central1/operations/operation-00000001", ""); code != http.StatusOK || + !strings.Contains(body, `"status":"DONE"`) { + t.Fatalf("gke op poll: code=%d body=%s (want status DONE)", code, body) + } + + // ...and gke's own cluster GET must not be swallowed by the LRO handler. + if code, _ := do(t, ts, http.MethodGet, + "/v1/projects/demo/locations/us-central1/clusters/k1", ""); code != http.StatusOK { + t.Fatalf("gke cluster GET: %d", code) + } +} + +// TestFullServerFirestoreCreate guards the #321 fix: a document write into a +// not-yet-existent collection auto-creates it (real Firestore), and a duplicate +// explicit id is ALREADY_EXISTS. +func TestFullServerFirestoreCreate(t *testing.T) { + ts := fullServer(t) + + const path = "/v1/projects/demo/databases/(default)/documents/users?documentId=alice" + body := `{"fields":{"name":{"stringValue":"Alice"}}}` + + if code, b := do(t, ts, http.MethodPost, path, body); code != http.StatusOK { + t.Fatalf("create doc in new collection: %d %s", code, b) + } + + if code, _ := do(t, ts, http.MethodPost, path, body); code != http.StatusConflict { + t.Fatalf("duplicate create: %d, want 409", code) + } +} + +// TestFullServerComputeDelete guards the #321 fix: instance delete removes the +// resource (GET after is 404), not a TERMINATED tombstone. +func TestFullServerComputeDelete(t *testing.T) { + ts := fullServer(t) + + const zone = "/compute/v1/projects/demo/zones/us-central1-a/instances" + + do(t, ts, http.MethodPost, zone, + `{"name":"vm1","machineType":"zones/us-central1-a/machineTypes/e2-medium"}`) + + if code, _ := do(t, ts, http.MethodGet, zone+"/vm1", ""); code != http.StatusOK { + t.Fatalf("GET before delete: %d", code) + } + + do(t, ts, http.MethodDelete, zone+"/vm1", "") + + if code, _ := do(t, ts, http.MethodGet, zone+"/vm1", ""); code != http.StatusNotFound { + t.Fatalf("GET after delete: %d, want 404", code) + } +} + +// TestFullServerGCSDoesNotSwallowAPIPaths guards the #321 fix: an unclaimed +// API-version path is NOT misrouted to GCS as a bogus bucket lookup. +func TestFullServerGCSDoesNotSwallowAPIPaths(t *testing.T) { + ts := fullServer(t) + + code, body := do(t, ts, http.MethodGet, "/v1/roles", "") + if strings.Contains(body, "bucket") { + t.Errorf("/v1/roles misrouted to GCS: code=%d body=%s", code, body) + } +} diff --git a/server/gcp/gcp.go b/server/gcp/gcp.go index a3208d9d..1a33d116 100644 --- a/server/gcp/gcp.go +++ b/server/gcp/gcp.go @@ -25,6 +25,7 @@ import ( "github.com/stackshy/cloudemu/v2/server/gcp/gke" "github.com/stackshy/cloudemu/v2/server/gcp/iam" lbsrv "github.com/stackshy/cloudemu/v2/server/gcp/loadbalancer" + "github.com/stackshy/cloudemu/v2/server/gcp/lro" memorystoresrv "github.com/stackshy/cloudemu/v2/server/gcp/memorystore" "github.com/stackshy/cloudemu/v2/server/gcp/monitoring" "github.com/stackshy/cloudemu/v2/server/gcp/networks" @@ -133,6 +134,13 @@ func New(d Drivers) *server.Server { srv := server.New() + // Shared location-operations poller. Registered FIRST so it owns every + // GET /v1/projects/{p}/locations/{l}/operations/{op} uniformly, instead of + // alloydb/gke greedily claiming (and 404ing) operations created by + // artifactregistry, eventarc, memorystore, etc. All emulated ops are + // synchronous, so a done response is always correct. + srv.Register(lro.New()) + if d.Compute != nil { srv.Register(compute.New(d.Compute)) } diff --git a/server/gcp/gcs/gcs_lifecycle_test.go b/server/gcp/gcs/gcs_lifecycle_test.go index 79125101..6a2bc941 100644 --- a/server/gcp/gcs/gcs_lifecycle_test.go +++ b/server/gcp/gcs/gcs_lifecycle_test.go @@ -678,10 +678,9 @@ func TestStorageTrailingBoundaryBytes(t *testing.T) { } } -// TestStorageVersioningSurface documents the provider-specific -// surface: versioning is a driver-level boolean only. The bucket resource -// reports it disabled, and the JSON API exposes no PATCH endpoint, so the -// SDK cannot enable it over HTTP. +// TestStorageVersioningSurface guards the #321 fix: bucket Update (PATCH) is +// now part of the HTTP surface, so enabling versioning over HTTP works and +// round-trips on a subsequent Attrs read. func TestStorageVersioningSurface(t *testing.T) { ctx, client := newStorageClient(t) bkt := mustCreateBucket(t, ctx, client, "e2e-versioning") @@ -695,12 +694,16 @@ func TestStorageVersioningSurface(t *testing.T) { t.Errorf("fresh bucket reports VersioningEnabled=true, want false (survey: default false)") } - // The GCS handler serves only GET/DELETE on /b/{bucket}; bucket Update - // (PATCH) is not part of the HTTP surface — versioning is driver-only. - _, err = bkt.Update(ctx, storage.BucketAttrsToUpdate{VersioningEnabled: true}) - if err == nil { - t.Fatalf("bucket Update(VersioningEnabled) unexpectedly succeeded; HTTP surface was believed to be GET/DELETE only") + if _, err := bkt.Update(ctx, storage.BucketAttrsToUpdate{VersioningEnabled: true}); err != nil { + t.Fatalf("bucket Update(VersioningEnabled) failed (the #321 fix): %v", err) } - t.Logf("bucket Update over HTTP rejected as expected: %v", err) + updated, err := bkt.Attrs(ctx) + if err != nil { + t.Fatalf("bucket Attrs after update: %v", err) + } + + if !updated.VersioningEnabled { + t.Error("VersioningEnabled did not round-trip after Update") + } } diff --git a/server/gcp/gcs/handler.go b/server/gcp/gcs/handler.go index f2ccee73..0fe79eab 100644 --- a/server/gcp/gcs/handler.go +++ b/server/gcp/gcs/handler.go @@ -77,11 +77,50 @@ func (*Handler) Matches(r *http.Request) bool { } // Direct media URLs are /{bucket}/{object}. Two or more path segments - // suffices. + // suffices — but NOT when the first segment is a reserved API prefix + // (v1, v2, sql, compute, …): those are other services' endpoints that no + // earlier handler claimed, and swallowing them here yields a misleading + // "bucket \"v1\" not found" instead of a clean not-implemented/not-found. trimmed := strings.TrimPrefix(p, "/") parts := strings.SplitN(trimmed, "/", pathBucketAndKey) - return len(parts) == pathBucketAndKey && parts[0] != "" && parts[1] != "" + if len(parts) != pathBucketAndKey || parts[0] == "" || parts[1] == "" { + return false + } + + return !isReservedAPIPrefix(parts[0]) +} + +// isReservedAPIPrefix reports whether a first path segment is a Google API +// version/service prefix rather than a plausible bucket name. GCS bucket names +// are lowercase and never collide with these in practice. +func isReservedAPIPrefix(seg string) bool { + switch seg { + case "sql", "compute", "dns", "upload", "storage", "download", "batch", "_cloudemu": + return true + } + + // A whole-segment API version token (v1, v3, v1beta4, v2beta) — but NOT a + // bucket that merely starts that way (e.g. "v2-assets", "v1data"). + return isVersionToken(seg) +} + +// isVersionToken reports whether seg is exactly an API version like v1, v3, +// v1beta4, v2beta — "v" + digits, optionally a beta/alpha qualifier, nothing +// else. A hyphen or other suffix (a real bucket name) is not a version. +func isVersionToken(seg string) bool { + if len(seg) < 2 || seg[0] != 'v' || seg[1] < '0' || seg[1] > '9' { + return false + } + + i := 1 + for i < len(seg) && seg[i] >= '0' && seg[i] <= '9' { + i++ + } + + rest := seg[i:] + + return rest == "" || strings.HasPrefix(rest, "beta") || strings.HasPrefix(rest, "alpha") } // ServeHTTP routes the request based on URL path shape. @@ -146,6 +185,8 @@ func (h *Handler) bucketResource(w http.ResponseWriter, r *http.Request, name st switch r.Method { case http.MethodGet: h.getBucket(w, r, name) + case http.MethodPatch, http.MethodPut: + h.patchBucket(w, r, name) case http.MethodDelete: h.deleteBucket(w, r, name) default: @@ -154,9 +195,7 @@ func (h *Handler) bucketResource(w http.ResponseWriter, r *http.Request, name st } func (h *Handler) createBucket(w http.ResponseWriter, r *http.Request) { - var body struct { - Name string `json:"name"` - } + var body bucketResource if !decodeJSON(w, r, &body) { return @@ -172,14 +211,21 @@ func (h *Handler) createBucket(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, bucketResource{ - Kind: "storage#bucket", - ID: body.Name, - Name: body.Name, - SelfLink: selfLink(r, "/storage/v1/b/"+body.Name), - Location: "US", - TimeCreated: time.Now().UTC().Format(time.RFC3339), - }) + // Persist configuration supplied at create so it round-trips on read. + if len(body.Labels) > 0 { + _ = h.bucket.PutBucketTagging(r.Context(), body.Name, body.Labels) + } + + if body.Versioning != nil && body.Versioning.Enabled { + _ = h.bucket.SetBucketVersioning(r.Context(), body.Name, true) + } + + res := h.bucketView(r, body.Name, time.Now().UTC().Format(time.RFC3339)) + if body.Location != "" { + res.Location = body.Location + } + + writeJSON(w, http.StatusOK, res) } func (h *Handler) listBuckets(w http.ResponseWriter, r *http.Request) { @@ -213,15 +259,7 @@ func (h *Handler) getBucket(w http.ResponseWriter, r *http.Request, name string) for _, b := range buckets { if b.Name == name { - writeJSON(w, http.StatusOK, bucketResource{ - Kind: "storage#bucket", - ID: b.Name, - Name: b.Name, - SelfLink: selfLink(r, "/storage/v1/b/"+b.Name), - Location: "US", - TimeCreated: b.CreatedAt, - }) - + writeJSON(w, http.StatusOK, h.bucketView(r, b.Name, b.CreatedAt)) return } } @@ -229,6 +267,57 @@ func (h *Handler) getBucket(w http.ResponseWriter, r *http.Request, name string) writeError(w, http.StatusNotFound, "notFound", "bucket "+name+" not found") } +// bucketView builds the bucket JSON with its configured versioning + labels +// reflected (real GCS returns these; the driver stores them so a read must +// surface them). +func (h *Handler) bucketView(r *http.Request, name, created string) bucketResource { + res := bucketResource{ + Kind: "storage#bucket", + ID: name, + Name: name, + SelfLink: selfLink(r, "/storage/v1/b/"+name), + Location: "US", + StorageClass: "STANDARD", + TimeCreated: created, + } + + if enabled, err := h.bucket.GetBucketVersioning(r.Context(), name); err == nil && enabled { + res.Versioning = &bucketVersioning{Enabled: true} + } + + if labels, err := h.bucket.GetBucketTagging(r.Context(), name); err == nil && len(labels) > 0 { + res.Labels = labels + } + + return res +} + +// patchBucket applies a bucket configuration update (versioning + labels), +// which real clients set via Buckets.Patch/Update. Without this the driver's +// versioning/label capabilities are unreachable over the wire. +func (h *Handler) patchBucket(w http.ResponseWriter, r *http.Request, name string) { + var body bucketResource + if !decodeJSON(w, r, &body) { + return + } + + if body.Versioning != nil { + if err := h.bucket.SetBucketVersioning(r.Context(), name, body.Versioning.Enabled); err != nil { + writeErr(w, err) + return + } + } + + if body.Labels != nil { + if err := h.bucket.PutBucketTagging(r.Context(), name, body.Labels); err != nil { + writeErr(w, err) + return + } + } + + writeJSON(w, http.StatusOK, h.bucketView(r, name, "")) +} + func (h *Handler) deleteBucket(w http.ResponseWriter, r *http.Request, name string) { if err := h.bucket.DeleteBucket(r.Context(), name); err != nil { writeErr(w, err) diff --git a/server/gcp/gcs/reserved_prefix_test.go b/server/gcp/gcs/reserved_prefix_test.go new file mode 100644 index 00000000..51520623 --- /dev/null +++ b/server/gcp/gcs/reserved_prefix_test.go @@ -0,0 +1,22 @@ +package gcs + +import "testing" + +// TestIsReservedAPIPrefix guards the review fix: API version/service segments +// are reserved (so API paths aren't misrouted to GCS as bucket lookups), but a +// real bucket that merely starts with "v"+digit (e.g. "v2-assets") is not. +func TestIsReservedAPIPrefix(t *testing.T) { + reserved := []string{"v1", "v3", "v1beta4", "v2beta", "sql", "compute", "dns", "upload"} + for _, s := range reserved { + if !isReservedAPIPrefix(s) { + t.Errorf("isReservedAPIPrefix(%q) = false, want true", s) + } + } + + buckets := []string{"v2-assets", "v1data", "my-bucket", "vault", "video", "v"} + for _, s := range buckets { + if isReservedAPIPrefix(s) { + t.Errorf("isReservedAPIPrefix(%q) = true, want false (real bucket name)", s) + } + } +} diff --git a/server/gcp/gcs/types.go b/server/gcp/gcs/types.go index 3092e06f..1950e19c 100644 --- a/server/gcp/gcs/types.go +++ b/server/gcp/gcs/types.go @@ -4,12 +4,19 @@ package gcs // Names map directly to the wire format the SDK expects. type bucketResource struct { - Kind string `json:"kind"` - ID string `json:"id"` - Name string `json:"name"` - SelfLink string `json:"selfLink,omitempty"` - Location string `json:"location,omitempty"` - TimeCreated string `json:"timeCreated,omitempty"` + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + SelfLink string `json:"selfLink,omitempty"` + Location string `json:"location,omitempty"` + StorageClass string `json:"storageClass,omitempty"` + Versioning *bucketVersioning `json:"versioning,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + TimeCreated string `json:"timeCreated,omitempty"` +} + +type bucketVersioning struct { + Enabled bool `json:"enabled"` } type bucketsListResponse struct { diff --git a/server/gcp/gke/sdk_roundtrip_test.go b/server/gcp/gke/sdk_roundtrip_test.go index 64447bc8..30c94037 100644 --- a/server/gcp/gke/sdk_roundtrip_test.go +++ b/server/gcp/gke/sdk_roundtrip_test.go @@ -118,6 +118,8 @@ func TestSDKGKEUpdateAndDelete(t *testing.T) { Update: &container.ClusterUpdate{ DesiredLoggingService: "none", DesiredMonitoringService: "none", + DesiredMasterVersion: "1.31.1-gke.0", + DesiredNodeVersion: "1.31.1-gke.0", }, }).Context(ctx).Do(); err != nil { t.Fatalf("update: %v", err) @@ -138,6 +140,15 @@ func TestSDKGKEUpdateAndDelete(t *testing.T) { t.Fatalf("got logging %q, want none", got.LoggingService) } + // The version upgrade must apply, not stay pinned at the stub version. + if got.CurrentMasterVersion != "1.31.1-gke.0" { + t.Fatalf("got currentMasterVersion %q, want 1.31.1-gke.0", got.CurrentMasterVersion) + } + + if got.CurrentNodeVersion != "1.31.1-gke.0" { + t.Fatalf("got currentNodeVersion %q, want 1.31.1-gke.0", got.CurrentNodeVersion) + } + if got.ResourceLabels["env"] != "test" { t.Fatalf("got label env=%q, want test", got.ResourceLabels["env"]) } diff --git a/server/gcp/gke/types.go b/server/gcp/gke/types.go index e93a0548..a0a9838e 100644 --- a/server/gcp/gke/types.go +++ b/server/gcp/gke/types.go @@ -197,8 +197,8 @@ func toClusterResource(c *gke.Cluster, project, endpoint string, pools []gke.Nod ClusterCaCertificate: k8spki.CertificatePEM(), }, Status: c.Status, - CurrentMasterVer: gke.StubMasterVer, - CurrentNodeVer: gke.StubMasterVer, + CurrentMasterVer: versionOr(c.MasterVersion), + CurrentNodeVer: versionOr(c.NodeVersion), SelfLink: "projects/" + project + "/locations/" + c.Location + "/clusters/" + c.Name, CreateTime: c.CreatedAt.Format("2006-01-02T15:04:05.000Z"), } @@ -210,6 +210,16 @@ func toClusterResource(c *gke.Cluster, project, endpoint string, pools []gke.Nod return out } +// versionOr returns the cluster's applied version, falling back to the stub +// version when none was set (i.e. no upgrade has been requested yet). +func versionOr(v string) string { + if v == "" { + return gke.StubMasterVer + } + + return v +} + func toNodePoolResource(np *gke.NodePool, project string) gkeNodePool { out := gkeNodePool{ Name: np.Name, diff --git a/server/gcp/iam/handler.go b/server/gcp/iam/handler.go index 74707897..26f6eb4d 100644 --- a/server/gcp/iam/handler.go +++ b/server/gcp/iam/handler.go @@ -36,6 +36,7 @@ package iam import ( "net/http" "strings" + "sync" iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" ) @@ -48,13 +49,24 @@ const ( ) // Handler serves iam.googleapis.com v1 REST requests against the IAM driver. +// +// Service-account resource policies and the enabled/disabled bit have no place +// in the portable IAM driver, so they're tracked here keyed by SA email. type Handler struct { iam iamdriver.IAM + + mu sync.RWMutex + saPolicy map[string]*iamPolicy // SA email -> resource policy + disabled map[string]bool // SA email -> disabled } // New returns an IAM handler backed by drv. func New(drv iamdriver.IAM) *Handler { - return &Handler{iam: drv} + return &Handler{ + iam: drv, + saPolicy: make(map[string]*iamPolicy), + disabled: make(map[string]bool), + } } // Matches returns true for any /v1/projects/{p}/{serviceAccounts|roles}[/…] @@ -84,6 +96,7 @@ type route struct { name string // SA email or role id, or "" for a collection subKind string // keysSeg, or "" for non-key paths subName string // key id, or "" for the collection + verb string // trailing ":method" (getIamPolicy, signBlob, …), or "" } // parseRoute splits the URL after /v1/projects/. Returns ok=false if the @@ -92,12 +105,21 @@ func parseRoute(urlPath string) (route, bool) { tail := strings.TrimPrefix(urlPath, pathPrefix) tail = strings.TrimRight(tail, "/") + // GCP one-off methods are POSTs to "…/{resource}:method". Split the trailing + // ":method" off the final segment before path splitting (SA emails and role + // ids contain no ':'). + var verb string + if i := strings.LastIndex(tail, ":"); i >= 0 { + verb = tail[i+1:] + tail = tail[:i] + } + parts := strings.Split(tail, "/") if len(parts) < 2 { //nolint:mnd // need at least project + kind return route{}, false } - r := route{project: parts[0], kind: parts[1]} + r := route{project: parts[0], kind: parts[1], verb: verb} if len(parts) >= 3 { //nolint:mnd // optional resource name segment r.name = parts[2] @@ -135,6 +157,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // routeServiceAccounts dispatches the /serviceAccounts/* surface. func (h *Handler) routeServiceAccounts(w http.ResponseWriter, r *http.Request, rt *route) { + // One-off ":method" calls (getIamPolicy, signBlob, …) are POSTs on a + // specific service account. + if rt.verb != "" && rt.name != "" { + h.routeSAVerb(w, r, rt) + return + } + switch { // Collection: POST create, GET list. case rt.name == "": diff --git a/server/gcp/iam/operations.go b/server/gcp/iam/operations.go index 546ebe4b..8ab8e737 100644 --- a/server/gcp/iam/operations.go +++ b/server/gcp/iam/operations.go @@ -52,6 +52,11 @@ func (h *Handler) getServiceAccount(w http.ResponseWriter, r *http.Request, proj } sa := saFromUser(user) + + h.mu.RLock() + sa.Disabled = h.disabled[email] + h.mu.RUnlock() + writeJSON(w, toServiceAccountJSON(project, email, &sa)) } diff --git a/server/gcp/iam/sdk_roundtrip_test.go b/server/gcp/iam/sdk_roundtrip_test.go index bfb361a4..13c7efc5 100644 --- a/server/gcp/iam/sdk_roundtrip_test.go +++ b/server/gcp/iam/sdk_roundtrip_test.go @@ -2,6 +2,7 @@ package iam_test import ( "context" + "encoding/base64" "errors" "net/http/httptest" "testing" @@ -215,3 +216,70 @@ func TestSDKGCPIAMNotFoundIsTyped(t *testing.T) { t.Fatalf("got HTTP %d, want 404", apiErr.Code) } } + +// TestSDKGCPIAMServiceAccountVerbs guards the #321 additions: SA-level +// getIamPolicy/setIamPolicy round-trip, signBlob returns a blob, and +// disable/enable toggle the SA's disabled bit. +func TestSDKGCPIAMServiceAccountVerbs(t *testing.T) { + svc := newSDKService(t) + ctx := context.Background() + + parent := "projects/" + testProject + + created, err := svc.Projects.ServiceAccounts.Create(parent, &iamv1.CreateServiceAccountRequest{ + AccountId: "verb-sa", + ServiceAccount: &iamv1.ServiceAccount{DisplayName: "Verb SA"}, + }).Context(ctx).Do() + if err != nil { + t.Fatalf("Create: %v", err) + } + + resource := "projects/" + testProject + "/serviceAccounts/" + created.Email + + // setIamPolicy then getIamPolicy must round-trip the binding. + if _, err := svc.Projects.ServiceAccounts.SetIamPolicy(resource, &iamv1.SetIamPolicyRequest{ + Policy: &iamv1.Policy{ + Bindings: []*iamv1.Binding{{ + Role: "roles/iam.serviceAccountUser", + Members: []string{"user:alice@example.com"}, + }}, + }, + }).Context(ctx).Do(); err != nil { + t.Fatalf("SetIamPolicy: %v", err) + } + + pol, err := svc.Projects.ServiceAccounts.GetIamPolicy(resource).Context(ctx).Do() + if err != nil { + t.Fatalf("GetIamPolicy: %v", err) + } + + if len(pol.Bindings) != 1 || pol.Bindings[0].Role != "roles/iam.serviceAccountUser" { + t.Fatalf("policy did not round-trip: %+v", pol.Bindings) + } + + // signBlob returns a non-empty blob. + sign, err := svc.Projects.ServiceAccounts.SignBlob(resource, &iamv1.SignBlobRequest{ + BytesToSign: base64.StdEncoding.EncodeToString([]byte("hello")), + }).Context(ctx).Do() + if err != nil { + t.Fatalf("SignBlob: %v", err) + } + + if sign.Signature == "" { + t.Error("SignBlob returned empty signature") + } + + // disable then Get shows disabled=true. + if _, err := svc.Projects.ServiceAccounts.Disable(resource, &iamv1.DisableServiceAccountRequest{}).Context(ctx).Do(); err != nil { + t.Fatalf("Disable: %v", err) + } + + got, err := svc.Projects.ServiceAccounts.Get(resource).Context(ctx).Do() + if err != nil { + t.Fatalf("Get after disable: %v", err) + } + + if !got.Disabled { + t.Error("SA not marked disabled after Disable") + } +} diff --git a/server/gcp/iam/types.go b/server/gcp/iam/types.go index e732d6cb..5fdc5c64 100644 --- a/server/gcp/iam/types.go +++ b/server/gcp/iam/types.go @@ -20,6 +20,7 @@ type serviceAccount struct { DisplayName string `json:"displayName,omitempty"` Description string `json:"description,omitempty"` OAuth2ClientID string `json:"oauth2ClientId,omitempty"` + Disabled bool `json:"disabled,omitempty"` Etag string `json:"etag,omitempty"` } diff --git a/server/gcp/iam/verbs.go b/server/gcp/iam/verbs.go new file mode 100644 index 00000000..7175a3ca --- /dev/null +++ b/server/gcp/iam/verbs.go @@ -0,0 +1,177 @@ +package iam + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "strconv" + "time" +) + +// iamPolicy is the GCP IAM Policy resource returned by getIamPolicy / +// setIamPolicy. Bindings are stored verbatim so a set/get round-trips. +type iamPolicy struct { + Version int `json:"version,omitempty"` + Bindings []policyBinding `json:"bindings,omitempty"` + Etag string `json:"etag,omitempty"` +} + +type policyBinding struct { + Role string `json:"role"` + Members []string `json:"members,omitempty"` +} + +// routeSAVerb dispatches the one-off ":method" service-account calls. All are +// POSTs on a specific SA. +func (h *Handler) routeSAVerb(w http.ResponseWriter, r *http.Request, rt *route) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "methodNotAllowed", + "method "+rt.verb+" requires POST") + + return + } + + // Confirm the SA exists (all these verbs act on an existing account). + if _, err := h.iam.GetUser(r.Context(), rt.name); err != nil { + writeCErr(w, err) + return + } + + switch rt.verb { + case "getIamPolicy": + h.getSAIamPolicy(w, rt.name) + case "setIamPolicy": + h.setSAIamPolicy(w, r, rt.name) + case "signBlob": + h.signBlob(w, r, rt.name) + case "signJwt": + h.signJwt(w, r, rt.name) + case "generateAccessToken": + h.generateAccessToken(w, r) + case "enable": + h.setDisabled(w, rt.name, false) + case "disable": + h.setDisabled(w, rt.name, true) + default: + writeError(w, http.StatusNotFound, "notFound", "unknown method: "+rt.verb) + } +} + +func (h *Handler) getSAIamPolicy(w http.ResponseWriter, email string) { + h.mu.RLock() + pol := h.saPolicy[email] + h.mu.RUnlock() + + if pol == nil { + // An SA with no policy yet returns an empty, versioned policy (matching + // real GCP, which never 404s getIamPolicy on an existing resource). + pol = &iamPolicy{Version: 1, Etag: etagFor(email, 0)} + } + + writeJSON(w, pol) +} + +func (h *Handler) setSAIamPolicy(w http.ResponseWriter, r *http.Request, email string) { + var body struct { + Policy iamPolicy `json:"policy"` + } + + if !decodeJSON(w, r, &body) { + return + } + + pol := body.Policy + if pol.Version == 0 { + pol.Version = 1 + } + + pol.Etag = etagFor(email, len(pol.Bindings)) + + h.mu.Lock() + h.saPolicy[email] = &pol + h.mu.Unlock() + + writeJSON(w, &pol) +} + +func (*Handler) signBlob(w http.ResponseWriter, r *http.Request, email string) { + // The iam.googleapis.com signBlob uses bytesToSign/signature (base64). + var body struct { + BytesToSign string `json:"bytesToSign"` + } + + if !decodeJSON(w, r, &body) { + return + } + + // Deterministic non-cryptographic "signature": a hash of the SA + payload. + // Real clients only need a stable, base64 blob back. + sig := sha256.Sum256([]byte(email + ":" + body.BytesToSign)) + + writeJSON(w, map[string]string{ + "keyId": "key-" + email, + "signature": base64.StdEncoding.EncodeToString(sig[:]), + }) +} + +func (*Handler) signJwt(w http.ResponseWriter, r *http.Request, email string) { + var body struct { + Payload string `json:"payload"` // JSON claims string + } + + if !decodeJSON(w, r, &body) { + return + } + + // A JWT-shaped (header.payload.signature) string; not cryptographically + // valid, but structurally what clients expect to parse. + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + claims := base64.RawURLEncoding.EncodeToString([]byte(body.Payload)) + sig := sha256.Sum256([]byte(email + body.Payload)) + sigStr := base64.RawURLEncoding.EncodeToString(sig[:]) + + writeJSON(w, map[string]string{ + "keyId": "key-" + email, + "signedJwt": header + "." + claims + "." + sigStr, + }) +} + +func (*Handler) generateAccessToken(w http.ResponseWriter, r *http.Request) { + var body struct { + Scope []string `json:"scope"` + Lifetime string `json:"lifetime"` + } + + _ = decodeJSON(w, r, &body) // request fields are optional for the stub + + expire := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + + writeJSON(w, map[string]string{ + "accessToken": "ya29.emulated-" + strconv.FormatInt(int64(len(body.Scope)), 10), + "expireTime": expire, + }) +} + +func (h *Handler) setDisabled(w http.ResponseWriter, email string, disabled bool) { + h.mu.Lock() + h.disabled[email] = disabled + h.mu.Unlock() + + writeJSON(w, map[string]any{}) +} + +// etagFor returns a stable etag for a policy state. +func etagFor(email string, n int) string { + return base64.StdEncoding.EncodeToString([]byte(email + ":" + strconv.Itoa(n))) +} + +// decodeJSON decodes a JSON request body, writing a 400 on failure. +func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { + if err := json.NewDecoder(r.Body).Decode(v); err != nil { + writeError(w, http.StatusBadRequest, "invalidArgument", "invalid JSON: "+err.Error()) + return false + } + + return true +} diff --git a/server/gcp/loadbalancer/operations.go b/server/gcp/loadbalancer/operations.go index fabaa668..5b6cee74 100644 --- a/server/gcp/loadbalancer/operations.go +++ b/server/gcp/loadbalancer/operations.go @@ -30,6 +30,9 @@ func (h *Handler) insertBackendService(w http.ResponseWriter, r *http.Request, r Name: req.Name, Protocol: req.Protocol, Port: req.Port, + // The driver TargetGroup can't hold these GCP fields, so round-trip them + // through tags rather than dropping them on read. + Tags: backendServiceTags(&req), }); err != nil { gcprest.WriteCErr(w, err) return @@ -231,7 +234,7 @@ func (h *Handler) findLBByName(ctx context.Context, name string) (*lbdriver.LBIn //nolint:gocritic // rp is a request-scoped value func toBackendServiceResponse(tg *lbdriver.TargetGroupInfo, rp gcprest.ResourcePath, host string) backendServiceResponse { - return backendServiceResponse{ + resp := backendServiceResponse{ Kind: "compute#backendService", ID: numericID(tg.ID), Name: tg.Name, @@ -239,6 +242,43 @@ func toBackendServiceResponse(tg *lbdriver.TargetGroupInfo, rp gcprest.ResourceP Port: tg.Port, SelfLink: gcprest.SelfLink(host, rp.Project, gcprest.ScopeGlobal, "", resourceBackendServices, tg.Name), } + + resp.Description = tg.Tags[bsDescriptionTag] + resp.PortName = tg.Tags[bsPortNameTag] + + if hc := tg.Tags[bsHealthChecksTag]; hc != "" { + resp.HealthChecks = strings.Split(hc, ",") + } + + return resp +} + +// Reserved tag keys carry the GCP backend-service fields the driver's target +// group can't model. +const ( + bsDescriptionTag = "cloudemu:gcpBsDescription" + bsPortNameTag = "cloudemu:gcpBsPortName" + bsHealthChecksTag = "cloudemu:gcpBsHealthChecks" +) + +// backendServiceTags folds the GCP-specific backend-service fields into a tag +// map so they round-trip through the driver. +func backendServiceTags(req *backendServiceRequest) map[string]string { + tags := map[string]string{} + + if req.Description != "" { + tags[bsDescriptionTag] = req.Description + } + + if req.PortName != "" { + tags[bsPortNameTag] = req.PortName + } + + if len(req.HealthChecks) > 0 { + tags[bsHealthChecksTag] = strings.Join(req.HealthChecks, ",") + } + + return tags } //nolint:gocritic // rp is a request-scoped value diff --git a/server/gcp/loadbalancer/sdk_roundtrip_test.go b/server/gcp/loadbalancer/sdk_roundtrip_test.go index f33c6be6..ba5ee68a 100644 --- a/server/gcp/loadbalancer/sdk_roundtrip_test.go +++ b/server/gcp/loadbalancer/sdk_roundtrip_test.go @@ -52,9 +52,12 @@ func TestSDKGCPBackendServiceRoundTrip(t *testing.T) { insertOp, err := client.Insert(ctx, &computepb.InsertBackendServiceRequest{ Project: testProject, BackendServiceResource: &computepb.BackendService{ - Name: ptrStr("web-backend"), - Protocol: ptrStr("HTTP"), - Port: func() *int32 { p := int32(80); return &p }(), + Name: ptrStr("web-backend"), + Protocol: ptrStr("HTTP"), + Port: func() *int32 { p := int32(80); return &p }(), + Description: ptrStr("web tier"), + PortName: ptrStr("http"), + HealthChecks: []string{"projects/p1/global/healthChecks/hc1"}, }, }) if err != nil { @@ -81,6 +84,19 @@ func TestSDKGCPBackendServiceRoundTrip(t *testing.T) { t.Fatalf("protocol = %q, want HTTP", got.GetProtocol()) } + // description / portName / healthChecks must round-trip, not be dropped. + if got.GetDescription() != "web tier" { + t.Errorf("description = %q, want 'web tier'", got.GetDescription()) + } + + if got.GetPortName() != "http" { + t.Errorf("portName = %q, want http", got.GetPortName()) + } + + if len(got.GetHealthChecks()) != 1 || got.GetHealthChecks()[0] != "projects/p1/global/healthChecks/hc1" { + t.Errorf("healthChecks = %v, want [.../hc1]", got.GetHealthChecks()) + } + // List. var names []string diff --git a/server/gcp/loadbalancer/types.go b/server/gcp/loadbalancer/types.go index ced9079b..b91c499b 100644 --- a/server/gcp/loadbalancer/types.go +++ b/server/gcp/loadbalancer/types.go @@ -16,13 +16,15 @@ type backendServiceRequest struct { } type backendServiceResponse struct { - Kind string `json:"kind"` - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Protocol string `json:"protocol,omitempty"` - Port int `json:"port,omitempty"` - SelfLink string `json:"selfLink"` + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Protocol string `json:"protocol,omitempty"` + Port int `json:"port,omitempty"` + PortName string `json:"portName,omitempty"` + HealthChecks []string `json:"healthChecks,omitempty"` + SelfLink string `json:"selfLink"` } type backendServiceListResponse struct { diff --git a/server/gcp/lro/handler.go b/server/gcp/lro/handler.go new file mode 100644 index 00000000..6e4b4e6b --- /dev/null +++ b/server/gcp/lro/handler.go @@ -0,0 +1,81 @@ +// Package lro provides a shared long-running-operation poller for GCP +// location-scoped operations: GET /v1/projects/{p}/locations/{l}/operations/{op}. +// +// In real GCP each service exposes its own operations endpoint on its own API +// host (alloydb.googleapis.com, artifactregistry.googleapis.com, …). CloudEmu +// collapses every service onto one HTTP server, so those per-service operation +// paths become indistinguishable by URL alone — whichever handler is registered +// first (alloydb/gke) would greedily answer every location operation poll and +// 404 the ones it didn't create, shadowing artifactregistry, eventarc, +// memorystore, etc. +// +// Every CloudEmu mutation completes synchronously (the create/delete response +// already carries done:true with the result inlined), so an operation poll only +// needs to report completion. This one handler, registered ahead of the +// service handlers, answers all location-scoped operation polls uniformly with +// a done operation — the single "operations host" the collapsed server needs. +package lro + +import ( + "net/http" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire/gcprest" +) + +const ( + pathPrefix = "/v1/projects/" + locationsSeg = "locations" + operationsSeg = "operations" +) + +// Handler answers GET on location-scoped operation names. +type Handler struct{} + +// New returns the shared location-operations handler. +func New() *Handler { return &Handler{} } + +// Matches claims GET /v1/projects/{p}/locations/{l}/operations/{op}. +func (*Handler) Matches(r *http.Request) bool { + if r.Method != http.MethodGet { + return false + } + + _, _, op, ok := parse(r.URL.Path) + + return ok && op != "" +} + +// ServeHTTP returns a completed operation echoing the polled name. +func (*Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + project, location, op, ok := parse(r.URL.Path) + if !ok { + gcprest.WriteError(w, http.StatusBadRequest, "invalid", "malformed operation path") + return + } + + // Return a superset that satisfies both operation schemas served here: + // google.longrunning.Operation reads `done` (artifactregistry, eventarc, + // memorystore, alloydb), while GKE's container.Operation reads `status`. + gcprest.WriteJSON(w, http.StatusOK, map[string]any{ + "name": "projects/" + project + "/locations/" + location + "/operations/" + op, + "done": true, + "status": "DONE", + }) +} + +// parse splits /v1/projects/{p}/locations/{l}/operations/{op}. +func parse(urlPath string) (project, location, op string, ok bool) { + if len(urlPath) < len(pathPrefix) || urlPath[:len(pathPrefix)] != pathPrefix { + return "", "", "", false + } + + parts := strings.Split(urlPath[len(pathPrefix):], "/") + // [project, locations, {l}, operations, {op}] + const want = 5 + if len(parts) != want || parts[1] != locationsSeg || parts[3] != operationsSeg { + return "", "", "", false + } + + return parts[0], parts[2], parts[4], true +} diff --git a/server/gcp/memorystore/handler.go b/server/gcp/memorystore/handler.go index 03160299..fa4bfedf 100644 --- a/server/gcp/memorystore/handler.go +++ b/server/gcp/memorystore/handler.go @@ -139,6 +139,8 @@ func (h *Handler) serveInstances(w http.ResponseWriter, r *http.Request, rt rout switch r.Method { case http.MethodGet: h.getInstance(w, r, rt) + case http.MethodPatch: + h.patchInstance(w, r, rt) case http.MethodDelete: h.deleteInstance(w, r, rt) default: diff --git a/server/gcp/memorystore/operations.go b/server/gcp/memorystore/operations.go index 7bcd32e5..823d3e2a 100644 --- a/server/gcp/memorystore/operations.go +++ b/server/gcp/memorystore/operations.go @@ -28,7 +28,7 @@ func (h *Handler) createInstance(w http.ResponseWriter, r *http.Request, rt rout Name: instanceID, Engine: "redis", NodeType: body.Tier, - Tags: body.Labels, + Tags: instanceTags(&body, nil), Scope: scope.Scope{Project: rt.project}, }) if err != nil { @@ -80,6 +80,48 @@ func (h *Handler) listInstances(w http.ResponseWriter, r *http.Request, rt route gcprest.WriteJSON(w, http.StatusOK, listInstancesResponse{Instances: out}) } +// patchInstance handles PATCH .../instances/{i} — Update. Real clients change +// memorySizeGb, displayName, tier, and labels here; without it those are stuck +// at their create-time values. +func (h *Handler) patchInstance(w http.ResponseWriter, r *http.Request, rt route) { + existing, err := h.cache.GetCache(r.Context(), rt.name) + if err != nil { + gcprest.WriteCErr(w, err) + return + } + + var body instanceJSON + if !gcprest.DecodeJSON(w, r, &body) { + return + } + + nodeType := existing.NodeType + if body.Tier != "" { + nodeType = body.Tier + } + + updated, err := h.cache.UpdateCache(r.Context(), cachedriver.CacheConfig{ + Name: rt.name, + Engine: "redis", + NodeType: nodeType, + Tags: instanceTags(&body, existing.Tags), + }) + if err != nil { + gcprest.WriteCErr(w, err) + return + } + + inst := toInstanceJSON(rt.project, rt.location, shortInstanceID(updated.Name), updated) + + raw, mErr := json.Marshal(inst) + if mErr != nil { + gcprest.WriteError(w, http.StatusInternalServerError, "internalError", mErr.Error()) + return + } + + gcprest.WriteJSON(w, http.StatusOK, doneOperation(rt.project, rt.location, "update-"+rt.name, raw)) +} + // deleteInstance handles DELETE .../instances/{i} — Delete. The operation // completes inline, so a done=true Operation with an empty response is returned. func (h *Handler) deleteInstance(w http.ResponseWriter, r *http.Request, rt route) { diff --git a/server/gcp/memorystore/sdk_roundtrip_test.go b/server/gcp/memorystore/sdk_roundtrip_test.go index fe3b96fa..4a4c7fee 100644 --- a/server/gcp/memorystore/sdk_roundtrip_test.go +++ b/server/gcp/memorystore/sdk_roundtrip_test.go @@ -53,6 +53,49 @@ func instanceName(id string) string { return parent() + "/instances/" + id } +// TestSDKMemorystoreConfigRoundTrip guards the #321 fixes: memorySizeGb, +// redisVersion and displayName round-trip (not hardcoded), and Update (PATCH) +// applies a new size. +func TestSDKMemorystoreConfigRoundTrip(t *testing.T) { + svc := newRedisService(t) + ctx := context.Background() + + if _, err := svc.Projects.Locations.Instances.Create(parent(), &redis.Instance{ + Tier: "STANDARD_HA", + MemorySizeGb: 5, + RedisVersion: "REDIS_7_0", + DisplayName: "prod cache", + }).InstanceId("big").Context(ctx).Do(); err != nil { + t.Fatalf("Create: %v", err) + } + + got, err := svc.Projects.Locations.Instances.Get(instanceName("big")).Context(ctx).Do() + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got.MemorySizeGb != 5 || got.RedisVersion != "REDIS_7_0" || got.DisplayName != "prod cache" { + t.Fatalf("config did not round-trip: size=%d version=%q display=%q", + got.MemorySizeGb, got.RedisVersion, got.DisplayName) + } + + // Update the size via PATCH. + if _, err := svc.Projects.Locations.Instances.Patch(instanceName("big"), &redis.Instance{ + MemorySizeGb: 8, + }).UpdateMask("memorySizeGb").Context(ctx).Do(); err != nil { + t.Fatalf("Patch (the #321 Update fix): %v", err) + } + + after, err := svc.Projects.Locations.Instances.Get(instanceName("big")).Context(ctx).Do() + if err != nil { + t.Fatalf("Get after patch: %v", err) + } + + if after.MemorySizeGb != 8 { + t.Errorf("after patch memorySizeGb=%d want 8", after.MemorySizeGb) + } +} + func TestSDKMemorystoreLifecycle(t *testing.T) { svc := newRedisService(t) ctx := context.Background() diff --git a/server/gcp/memorystore/types.go b/server/gcp/memorystore/types.go index 6b86851b..c6a19b9d 100644 --- a/server/gcp/memorystore/types.go +++ b/server/gcp/memorystore/types.go @@ -104,18 +104,94 @@ func toInstanceJSON(project, location, instanceID string, info *cachedriver.Cach tier = info.NodeType } + memSize := int64(1) + if v, err := strconv.ParseInt(info.Tags[memorySizeTag], 10, 64); err == nil && v > 0 { + memSize = v + } + + redisVersion := defaultRedisVersion + if v := info.Tags[redisVersionTag]; v != "" { + redisVersion = v + } + return instanceJSON{ - Name: instanceResourceName(project, location, instanceID), - Tier: tier, - MemorySizeGb: 1, - RedisVersion: defaultRedisVersion, - State: stateOrReady(info.Status), - Host: host, - Port: port, - CreateTime: info.CreatedAt, - Labels: info.Tags, - LocationID: location, + Name: instanceResourceName(project, location, instanceID), + DisplayName: info.Tags[displayNameTag], + Tier: tier, + MemorySizeGb: memSize, + RedisVersion: redisVersion, + State: stateOrReady(info.Status), + Host: host, + Port: port, + CreateTime: info.CreatedAt, + Labels: stripReservedTags(info.Tags), + LocationID: location, + ReservedIPRng: info.Tags[reservedIPTag], + } +} + +// Reserved tag keys carry GCP-specific fields the cache driver can't model, so +// they round-trip through the cache's tags. +const ( + memorySizeTag = "cloudemu:gcpMemorySizeGb" + redisVersionTag = "cloudemu:gcpRedisVersion" + displayNameTag = "cloudemu:gcpDisplayName" + reservedIPTag = "cloudemu:gcpReservedIpRange" +) + +// instanceTags folds the GCP-specific request fields into the tag map, layered +// over existing tags so a partial PATCH keeps unspecified values. +func instanceTags(body *instanceJSON, existing map[string]string) map[string]string { + out := make(map[string]string, len(existing)+len(body.Labels)) + + for k, v := range existing { + out[k] = v + } + + for k, v := range body.Labels { + out[k] = v + } + + if body.MemorySizeGb > 0 { + out[memorySizeTag] = strconv.FormatInt(body.MemorySizeGb, 10) + } + + if body.RedisVersion != "" { + out[redisVersionTag] = body.RedisVersion } + + if body.DisplayName != "" { + out[displayNameTag] = body.DisplayName + } + + if body.ReservedIPRng != "" { + out[reservedIPTag] = body.ReservedIPRng + } + + return out +} + +// stripReservedTags returns user labels without cloudemu-internal keys. +func stripReservedTags(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + + out := make(map[string]string, len(in)) + + for k, v := range in { + if strings.HasPrefix(k, "cloudemu:") { + continue + } + + out[k] = v + } + + if len(out) == 0 { + return nil + } + + return out } // stateOrReady maps the driver status onto Memorystore's state enum, defaulting diff --git a/server/gcp/monitoring/handler.go b/server/gcp/monitoring/handler.go index 048c0aef..22eeba98 100644 --- a/server/gcp/monitoring/handler.go +++ b/server/gcp/monitoring/handler.go @@ -11,10 +11,12 @@ package monitoring import ( - "context" "encoding/json" "net/http" + "strconv" "strings" + "sync" + "sync/atomic" cerrors "github.com/stackshy/cloudemu/v2/errors" mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" @@ -26,13 +28,22 @@ const ( ) // Handler serves GCP Cloud Monitoring alert-policy REST requests. +// +// The portable monitoring driver models a threshold Alarm, not GCP's richer +// alert-policy shape (conditions, combiner, notificationChannels, userLabels). +// To avoid dropping those on read, the full policy is held here keyed by name; +// the driver alarm is kept as an existence marker. type Handler struct { mon mondriver.Monitoring + + mu sync.RWMutex + policies map[string]alertPolicy // keyed by policy short-name + seq atomic.Uint64 } // New returns a Cloud Monitoring handler. func New(m mondriver.Monitoring) *Handler { - return &Handler{mon: m} + return &Handler{mon: m, policies: make(map[string]alertPolicy)} } // Matches returns true for /v3/projects/.../alertPolicies URLs. @@ -67,6 +78,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: h.getPolicy(w, r, project, name) + case http.MethodPatch, http.MethodPut: + h.patchPolicy(w, r, project, name) case http.MethodDelete: h.deletePolicy(w, r, name) default: @@ -97,9 +110,11 @@ func (h *Handler) createPolicy(w http.ResponseWriter, r *http.Request, project s name := body.DisplayName if name == "" { - name = "policy-" + randID() + name = "policy-" + strconv.FormatUint(h.seq.Add(1), 10) } + // The driver alarm is only an existence marker; the full policy shape lives + // in the handler registry so conditions/combiner/labels round-trip. cfg := mondriver.AlarmConfig{ Name: name, Namespace: "gcp", @@ -116,77 +131,129 @@ func (h *Handler) createPolicy(w http.ResponseWriter, r *http.Request, project s return } - body.Name = "projects/" + project + "/alertPolicies/" + name + body.Name = policyResourceName(project, name) + + h.mu.Lock() + h.policies[name] = body + h.mu.Unlock() writeJSON(w, http.StatusOK, body) } -func (h *Handler) getPolicy(w http.ResponseWriter, r *http.Request, project, name string) { - if err := policyExists(r.Context(), h.mon, name); err != nil { - writeErr(w, err) +func (h *Handler) getPolicy(w http.ResponseWriter, _ *http.Request, project, name string) { + h.mu.RLock() + pol, ok := h.policies[name] + h.mu.RUnlock() + + if !ok { + writeError(w, http.StatusNotFound, "NOT_FOUND", "alertPolicy "+name+" not found") return } - writeJSON(w, http.StatusOK, alertPolicy{ - Name: "projects/" + project + "/alertPolicies/" + name, - DisplayName: name, - Enabled: true, - }) + pol.Name = policyResourceName(project, name) + + writeJSON(w, http.StatusOK, pol) } -func (h *Handler) listPolicies(w http.ResponseWriter, r *http.Request, project string) { - alarms, err := h.mon.DescribeAlarms(r.Context(), nil) - if err != nil { - writeErr(w, err) - return - } +func (h *Handler) listPolicies(w http.ResponseWriter, _ *http.Request, project string) { + h.mu.RLock() + out := alertPoliciesList{AlertPolicies: make([]alertPolicy, 0, len(h.policies))} - out := alertPoliciesList{} - for i := range alarms { - out.AlertPolicies = append(out.AlertPolicies, alertPolicy{ - Name: "projects/" + project + "/alertPolicies/" + alarms[i].Name, - DisplayName: alarms[i].Name, - Enabled: true, - }) + for name := range h.policies { + pol := h.policies[name] + pol.Name = policyResourceName(project, name) + out.AlertPolicies = append(out.AlertPolicies, pol) } + h.mu.RUnlock() writeJSON(w, http.StatusOK, out) } -func (h *Handler) deletePolicy(w http.ResponseWriter, r *http.Request, name string) { - if err := policyExists(r.Context(), h.mon, name); err != nil { - writeErr(w, err) +// patchPolicy applies a partial update. GCP scopes changes by updateMask; the +// pragmatic emulation overwrites any field the caller supplied (non-zero), +// which covers displayName/combiner/enabled/conditions/labels/channels. +func (h *Handler) patchPolicy(w http.ResponseWriter, r *http.Request, project, name string) { + // Decode with a pointer Enabled so an omitted "enabled" is distinguishable + // from an explicit false — a partial PATCH must leave it unchanged, not + // silently disable the policy (real GCP applies only the updateMask paths). + var body struct { + DisplayName string `json:"displayName"` + Combiner string `json:"combiner"` + Conditions []alertCondition `json:"conditions"` + UserLabels map[string]string `json:"userLabels"` + NotificationChannels []string `json:"notificationChannels"` + Enabled *bool `json:"enabled"` + } + + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error()) return } - if err := h.mon.DeleteAlarm(r.Context(), name); err != nil { - writeErr(w, err) + h.mu.Lock() + + cur, ok := h.policies[name] + if !ok { + h.mu.Unlock() + writeError(w, http.StatusNotFound, "NOT_FOUND", "alertPolicy "+name+" not found") + return } - writeJSON(w, http.StatusOK, map[string]any{}) + if body.DisplayName != "" { + cur.DisplayName = body.DisplayName + } + + if body.Combiner != "" { + cur.Combiner = body.Combiner + } + + if body.Conditions != nil { + cur.Conditions = body.Conditions + } + + if body.UserLabels != nil { + cur.UserLabels = body.UserLabels + } + + if body.NotificationChannels != nil { + cur.NotificationChannels = body.NotificationChannels + } + + if body.Enabled != nil { + cur.Enabled = *body.Enabled + } + + h.policies[name] = cur + h.mu.Unlock() + + cur.Name = policyResourceName(project, name) + + writeJSON(w, http.StatusOK, cur) } -// policyExists reports whether an alert policy exists by name. -func policyExists(ctx context.Context, m mondriver.Monitoring, name string) error { - alarms, err := m.DescribeAlarms(ctx, nil) - if err != nil { - return err +func (h *Handler) deletePolicy(w http.ResponseWriter, r *http.Request, name string) { + h.mu.Lock() + _, ok := h.policies[name] + delete(h.policies, name) + h.mu.Unlock() + + if !ok { + writeError(w, http.StatusNotFound, "NOT_FOUND", "alertPolicy "+name+" not found") + return } - for i := range alarms { - if alarms[i].Name == name { - return nil - } + if err := h.mon.DeleteAlarm(r.Context(), name); err != nil && !cerrors.IsNotFound(err) { + writeErr(w, err) + return } - return cerrors.Newf(cerrors.NotFound, "alertPolicy %s not found", name) + writeJSON(w, http.StatusOK, map[string]any{}) } -// randID returns a small random identifier for synthesized policy names. -// Stable enough for HTTP-level tests; not cryptographic. -func randID() string { - return "auto" +func policyResourceName(project, name string) string { + return "projects/" + project + "/alertPolicies/" + name } func writeJSON(w http.ResponseWriter, status int, v any) { diff --git a/server/gcp/monitoring/monitoring_test.go b/server/gcp/monitoring/monitoring_test.go index f22bc73b..0b1a660f 100644 --- a/server/gcp/monitoring/monitoring_test.go +++ b/server/gcp/monitoring/monitoring_test.go @@ -91,3 +91,122 @@ func TestMonitoringAlertPolicyCRUD(t *testing.T) { t.Errorf("delete status=%d", delResp.StatusCode) } } + +// TestMonitoringAlertPolicySemantics guards the #321 fix: a policy's +// conditions/combiner/enabled/userLabels must round-trip on Get (not be +// dropped for a hardcoded skeleton), and PATCH must apply. +func TestMonitoringAlertPolicySemantics(t *testing.T) { + cloudP := cloudemu.NewGCP() + srv := gcpserver.New(gcpserver.Drivers{Monitoring: cloudP.CloudMonitoring}) + + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + + const collURL = "/v3/projects/p1/alertPolicies" + + create := bytes.NewBufferString(`{ + "displayName": "cpu-alert", + "combiner": "AND", + "enabled": true, + "userLabels": {"team": "sre"}, + "conditions": [{"displayName": "cpu>80"}] + }`) + + resp, err := ts.Client().Post(ts.URL+collURL, "application/json", create) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + // Get must reflect what was created. + getResp, err := ts.Client().Get(ts.URL + collURL + "/cpu-alert") + if err != nil { + t.Fatal(err) + } + defer getResp.Body.Close() + + var got map[string]any + _ = json.NewDecoder(getResp.Body).Decode(&got) + + if got["combiner"] != "AND" { + t.Errorf("combiner=%v want AND (dropped on read)", got["combiner"]) + } + + if got["enabled"] != true { + t.Errorf("enabled=%v want true", got["enabled"]) + } + + if ul, _ := got["userLabels"].(map[string]any); ul["team"] != "sre" { + t.Errorf("userLabels=%v want team=sre", got["userLabels"]) + } + + if conds, _ := got["conditions"].([]any); len(conds) != 1 { + t.Errorf("conditions=%v want 1", got["conditions"]) + } + + // PATCH updates the combiner but OMITS enabled — a partial patch must NOT + // silently disable the policy (regression guard for the omitted-field bug). + patch := bytes.NewBufferString(`{"combiner": "OR"}`) + patchReq, _ := http.NewRequest(http.MethodPatch, ts.URL+collURL+"/cpu-alert", patch) + patchReq.Header.Set("Content-Type", "application/json") + + patchResp, err := ts.Client().Do(patchReq) + if err != nil { + t.Fatal(err) + } + defer patchResp.Body.Close() + + var patched map[string]any + _ = json.NewDecoder(patchResp.Body).Decode(&patched) + + if patched["combiner"] != "OR" { + t.Errorf("after PATCH combiner=%v want OR", patched["combiner"]) + } + + if patched["enabled"] != true { + t.Errorf("after PATCH omitting enabled, enabled=%v want true (must not silently disable)", patched["enabled"]) + } +} + +// TestMonitoringNonThresholdCondition guards that a non-threshold condition +// (conditionAbsent) round-trips instead of being dropped. +func TestMonitoringNonThresholdCondition(t *testing.T) { + cloudP := cloudemu.NewGCP() + srv := gcpserver.New(gcpserver.Drivers{Monitoring: cloudP.CloudMonitoring}) + + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + + const collURL = "/v3/projects/p1/alertPolicies" + + create := bytes.NewBufferString(`{ + "displayName": "absent-alert", + "combiner": "OR", + "conditions": [{"displayName": "no data", "conditionAbsent": {"duration": "300s"}}] + }`) + + resp, err := ts.Client().Post(ts.URL+collURL, "application/json", create) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + getResp, err := ts.Client().Get(ts.URL + collURL + "/absent-alert") + if err != nil { + t.Fatal(err) + } + defer getResp.Body.Close() + + var got map[string]any + _ = json.NewDecoder(getResp.Body).Decode(&got) + + conds, _ := got["conditions"].([]any) + if len(conds) != 1 { + t.Fatalf("conditions=%v want 1", got["conditions"]) + } + + c0, _ := conds[0].(map[string]any) + if _, ok := c0["conditionAbsent"]; !ok { + t.Errorf("conditionAbsent dropped on round-trip: %+v", c0) + } +} diff --git a/server/gcp/monitoring/types.go b/server/gcp/monitoring/types.go index 9b2cdf4b..efb1bea6 100644 --- a/server/gcp/monitoring/types.go +++ b/server/gcp/monitoring/types.go @@ -15,9 +15,17 @@ type alertPolicy struct { MutationRecord any `json:"mutationRecord,omitempty"` } +// alertCondition round-trips every Cloud Monitoring condition variant, not just +// conditionThreshold — conditionAbsent / MQL / PromQL / matchedLog are carried +// verbatim so they survive a create→read cycle instead of being silently dropped. type alertCondition struct { - Name string `json:"name,omitempty"` - DisplayName string `json:"displayName,omitempty"` + Name string `json:"name,omitempty"` + DisplayName string `json:"displayName,omitempty"` + ConditionThreshold any `json:"conditionThreshold,omitempty"` + ConditionAbsent any `json:"conditionAbsent,omitempty"` + ConditionMatchedLog any `json:"conditionMatchedLog,omitempty"` + ConditionMonitoringQueryLanguage any `json:"conditionMonitoringQueryLanguage,omitempty"` + ConditionPrometheusQueryLanguage any `json:"conditionPrometheusQueryLanguage,omitempty"` } type alertPoliciesList struct { diff --git a/server/gcp/networks/handler.go b/server/gcp/networks/handler.go index f898a63a..1b4193b5 100644 --- a/server/gcp/networks/handler.go +++ b/server/gcp/networks/handler.go @@ -23,8 +23,10 @@ package networks import ( "context" + "encoding/json" "net/http" "strconv" + "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" @@ -39,7 +41,11 @@ const ( resourceAddresses = "addresses" netNameTag = "cloudemu:gcpNetName" subnetNameTag = "cloudemu:gcpSubnetName" + subnetNetworkTag = "cloudemu:gcpSubnetNet" + autoSubnetTag = "cloudemu:gcpAutoSubnet" firewallNameTag = "cloudemu:gcpFwName" + firewallSpecTag = "cloudemu:gcpFwSpec" + trueValue = "true" ) // Handler serves the GCP networking REST surface. @@ -188,14 +194,26 @@ func (h *Handler) insertNetwork(w http.ResponseWriter, r *http.Request, rp gcpre return } + if _, err := findNetByName(r.Context(), h.net, req.Name); err == nil { + gcprest.WriteError(w, http.StatusConflict, "alreadyExists", + "network "+req.Name+" already exists") + + return + } + cidr := "10.0.0.0/16" if req.IPv4Range != "" { cidr = req.IPv4Range } + tags := map[string]string{netNameTag: req.Name} + if req.AutoCreateSubnetworks != nil && *req.AutoCreateSubnetworks { + tags[autoSubnetTag] = trueValue + } + cfg := netdriver.VPCConfig{ CIDRBlock: cidr, - Tags: map[string]string{netNameTag: req.Name}, + Tags: tags, } if _, err := h.net.CreateVPC(r.Context(), cfg); err != nil { @@ -294,7 +312,7 @@ func (h *Handler) insertSubnetwork(w http.ResponseWriter, r *http.Request, rp gc VPCID: vpcID, CIDRBlock: req.IPCIDRRange, AvailabilityZone: rp.ScopeName, - Tags: map[string]string{subnetNameTag: req.Name}, + Tags: map[string]string{subnetNameTag: req.Name, subnetNetworkTag: lastSegment(req.Network)}, } if _, err := h.net.CreateSubnet(r.Context(), cfg); err != nil { @@ -401,11 +419,20 @@ func (h *Handler) insertFirewall(w http.ResponseWriter, r *http.Request, rp gcpr } } + // The driver's SecurityGroup model can't express GCP's firewall shape + // (allowed/denied/direction/priority/targetTags), so persist the rule spec + // verbatim in a reserved tag and reconstruct it on read. Without this a + // created firewall reads back with no rules. + tags := map[string]string{firewallNameTag: req.Name} + if spec := marshalFirewallSpec(&req); spec != "" { + tags[firewallSpecTag] = spec + } + cfg := netdriver.SecurityGroupConfig{ Name: req.Name, Description: req.Description, VPCID: vpcID, - Tags: map[string]string{firewallNameTag: req.Name}, + Tags: tags, } if _, err := h.net.CreateSecurityGroup(r.Context(), cfg); err != nil { @@ -564,11 +591,21 @@ func toNetworkResponse(info *netdriver.VPCInfo, rp gcprest.ResourcePath, host st ID: numericID(info.ID), Name: name, IPv4Range: info.CIDRBlock, - AutoCreateSubnetworks: false, + AutoCreateSubnetworks: info.Tags[autoSubnetTag] == trueValue, SelfLink: gcprest.SelfLink(host, rp.Project, gcprest.ScopeGlobal, "", "networks", name), } } +// lastSegment returns the final path/URL segment (e.g. a network self-link or +// partial ref reduced to its bare name). +func lastSegment(ref string) string { + if i := strings.LastIndex(ref, "/"); i >= 0 { + return ref[i+1:] + } + + return ref +} + //nolint:gocritic // rp is a request-scoped value func toSubnetworkResponse(info *netdriver.SubnetInfo, rp gcprest.ResourcePath, host string) subnetworkResponse { name := tagOr(info.Tags, subnetNameTag, info.ID) @@ -578,7 +615,7 @@ func toSubnetworkResponse(info *netdriver.SubnetInfo, rp gcprest.ResourcePath, h region = info.AvailabilityZone } - return subnetworkResponse{ + resp := subnetworkResponse{ Kind: "compute#subnetwork", ID: numericID(info.ID), Name: name, @@ -586,19 +623,83 @@ func toSubnetworkResponse(info *netdriver.SubnetInfo, rp gcprest.ResourcePath, h Region: host + "/compute/v1/projects/" + rp.Project + "/regions/" + region, SelfLink: gcprest.SelfLink(host, rp.Project, gcprest.ScopeRegions, region, "subnetworks", name), } + + // Echo the parent network self-link so clients can discover a subnet's + // network (real GCP always returns it). + if net := info.Tags[subnetNetworkTag]; net != "" { + resp.Network = gcprest.SelfLink(host, rp.Project, gcprest.ScopeGlobal, "", "networks", net) + } + + return resp } //nolint:gocritic // rp is a request-scoped value func toFirewallResponse(info *netdriver.SecurityGroupInfo, rp gcprest.ResourcePath, host string) firewallResponse { name := tagOr(info.Tags, firewallNameTag, info.ID) - return firewallResponse{ + resp := firewallResponse{ Kind: "compute#firewall", ID: numericID(info.ID), Name: name, Description: info.Description, SelfLink: gcprest.SelfLink(host, rp.Project, gcprest.ScopeGlobal, "", "firewalls", name), } + + if spec, ok := unmarshalFirewallSpec(info.Tags[firewallSpecTag]); ok { + resp.Network = spec.Network + resp.Priority = spec.Priority + resp.Direction = spec.Direction + resp.Allowed = spec.Allowed + resp.Denied = spec.Denied + resp.SourceRanges = spec.SourceRanges + resp.TargetTags = spec.TargetTags + } + + return resp +} + +// firewallSpec is the GCP firewall rule shape persisted verbatim (as JSON in a +// reserved tag) because the driver's SecurityGroup model can't express it. +type firewallSpec struct { + Network string `json:"network,omitempty"` + Priority int `json:"priority,omitempty"` + Direction string `json:"direction,omitempty"` + Allowed []firewallRule `json:"allowed,omitempty"` + Denied []firewallRule `json:"denied,omitempty"` + SourceRanges []string `json:"sourceRanges,omitempty"` + TargetTags []string `json:"targetTags,omitempty"` +} + +func marshalFirewallSpec(req *firewallRequest) string { + spec := firewallSpec{ + Network: req.Network, + Priority: req.Priority, + Direction: req.Direction, + Allowed: req.Allowed, + Denied: req.Denied, + SourceRanges: req.SourceRanges, + TargetTags: req.TargetTags, + } + + b, err := json.Marshal(spec) + if err != nil { + return "" + } + + return string(b) +} + +func unmarshalFirewallSpec(s string) (firewallSpec, bool) { + if s == "" { + return firewallSpec{}, false + } + + var spec firewallSpec + if err := json.Unmarshal([]byte(s), &spec); err != nil { + return firewallSpec{}, false + } + + return spec, true } func tagOr(m map[string]string, key, fallback string) string { diff --git a/server/gcp/networks/networks_test.go b/server/gcp/networks/networks_test.go index c3dbd852..0172c2d5 100644 --- a/server/gcp/networks/networks_test.go +++ b/server/gcp/networks/networks_test.go @@ -19,6 +19,7 @@ const ( ) func ptrStr(s string) *string { return &s } +func ptrInt32(i int32) *int32 { return &i } func newGCPNetServer(t *testing.T) *httptest.Server { t.Helper() @@ -126,8 +127,12 @@ func TestSDKFirewallRoundTrip(t *testing.T) { Name: ptrStr("fw-1"), Allowed: []*computepb.Allowed{{ IPProtocol: ptrStr("tcp"), - Ports: []string{"80"}, + Ports: []string{"80", "443"}, }}, + SourceRanges: []string{"10.0.0.0/8"}, + Direction: ptrStr("INGRESS"), + Priority: ptrInt32(900), + TargetTags: []string{"web"}, }, }) if err != nil { @@ -149,6 +154,24 @@ func TestSDKFirewallRoundTrip(t *testing.T) { t.Errorf("name=%s want fw-1", got.GetName()) } + // #321: firewall rules must round-trip, not read back empty. + allowed := got.GetAllowed() + if len(allowed) != 1 || allowed[0].GetIPProtocol() != "tcp" || len(allowed[0].GetPorts()) != 2 { + t.Fatalf("allowed did not round-trip: %+v", allowed) + } + + if len(got.GetSourceRanges()) != 1 || got.GetSourceRanges()[0] != "10.0.0.0/8" { + t.Errorf("sourceRanges=%v", got.GetSourceRanges()) + } + + if got.GetDirection() != "INGRESS" || got.GetPriority() != 900 { + t.Errorf("direction=%s priority=%d", got.GetDirection(), got.GetPriority()) + } + + if len(got.GetTargetTags()) != 1 || got.GetTargetTags()[0] != "web" { + t.Errorf("targetTags=%v", got.GetTargetTags()) + } + delOp, err := client.Delete(ctx, &computepb.DeleteFirewallRequest{ Project: testProject, Firewall: "fw-1", }) diff --git a/server/gcp/pubsub/handler.go b/server/gcp/pubsub/handler.go index 47c09f79..89dca9be 100644 --- a/server/gcp/pubsub/handler.go +++ b/server/gcp/pubsub/handler.go @@ -26,9 +26,14 @@ package pubsub import ( "encoding/base64" "encoding/json" + "errors" "fmt" + "io" "net/http" + "sort" "strings" + "sync" + "time" cerrors "github.com/stackshy/cloudemu/v2/errors" mqdriver "github.com/stackshy/cloudemu/v2/services/messagequeue/driver" @@ -49,13 +54,31 @@ const ( ) // Handler serves Pub/Sub v1 REST requests against a messagequeue driver. +// +// The portable messagequeue driver has one queue per topic and no separate +// subscription concept, so subscription identity + metadata (its topic, +// ackDeadline, labels) is tracked here. Messages still live in the topic's +// queue; a subscription resolves to that queue for pull/ack. This lets a +// subscription carry a name distinct from its topic. (Multiple subscriptions +// on one topic share the single underlying queue rather than each getting an +// independent copy — a documented emulator simplification.) type Handler struct { mq mqdriver.MessageQueue + + mu sync.RWMutex + subs map[string]*subMeta // keyed by subscription short-name +} + +// subMeta is the per-subscription metadata the driver can't hold. +type subMeta struct { + topic string // topic short-name whose queue backs this subscription + ackDeadline int + labels map[string]string } // New returns a Pub/Sub handler backed by mq. func New(mq mqdriver.MessageQueue) *Handler { - return &Handler{mq: mq} + return &Handler{mq: mq, subs: make(map[string]*subMeta)} } // Matches accepts /v1/projects/{p}/topics[...] and /v1/projects/{p}/subscriptions[...]. @@ -150,13 +173,31 @@ func (h *Handler) serveCollection(w http.ResponseWriter, r *http.Request, projec writeJSON(w, http.StatusOK, out) case resSubscriptions: - out := listSubscriptionsResponse{Subscriptions: make([]subscription, 0, len(queues))} - for i := range queues { + // List from the subscription registry (not one phantom sub per queue), + // so distinct sub/topic names and their ackDeadline/labels round-trip. + // Emit in sorted name order: Go map iteration is randomized, and the + // repo's list endpoints are deterministic. + h.mu.RLock() + subNames := make([]string, 0, len(h.subs)) + + for subName := range h.subs { + subNames = append(subNames, subName) + } + + sort.Strings(subNames) + + out := listSubscriptionsResponse{Subscriptions: make([]subscription, 0, len(subNames))} + + for _, subName := range subNames { + meta := h.subs[subName] out.Subscriptions = append(out.Subscriptions, subscription{ - Name: subscriptionName(project, queues[i].Name), - Topic: topicName(project, queues[i].Name), + Name: subscriptionName(project, subName), + Topic: topicName(project, meta.topic), + AckDeadlineSeconds: meta.ackDeadline, + Labels: meta.labels, }) } + h.mu.RUnlock() writeJSON(w, http.StatusOK, out) default: @@ -185,8 +226,13 @@ func (h *Handler) serveTopic(w http.ResponseWriter, r *http.Request, project, na } func (h *Handler) createTopic(w http.ResponseWriter, r *http.Request, project, name string) { + // Real Pub/Sub createTopic accepts an empty body, so tolerate EOF/empty + // rather than 400ing on a bodyless request. var body topic - _ = decodeJSON(w, r, &body) // topic body is mostly empty for create; tolerate it + if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "INVALID_ARGUMENT", "invalid JSON: "+err.Error()) + return + } info, err := h.mq.CreateQueue(r.Context(), mqdriver.QueueConfig{Name: name, Tags: body.Labels}) if err != nil { @@ -301,56 +347,86 @@ func (h *Handler) createSubscription(w http.ResponseWriter, r *http.Request, pro return } - // The driver pairs topic+subscription under a single queue; require the - // subscription name to match a known queue (which represents the topic). - if _, err := h.findQueueByName(r, name); err != nil { - // Auto-create from the topic field if present and matches the sub name. - if subToTopicName(body.Topic) != name { - writeErr(w, err) - return - } - - if _, cerr := h.mq.CreateQueue(r.Context(), mqdriver.QueueConfig{Name: name}); cerr != nil && - !cerrors.IsAlreadyExists(cerr) { - writeErr(w, cerr) - return - } + // The topic (a driver queue) must exist. Its short name may differ from + // the subscription name; default to the subscription name only when the + // caller omitted the topic (tolerant legacy path). + topicShort := subToTopicName(body.Topic) + if topicShort == "" { + topicShort = name } - resp := subscription{ - Name: subscriptionName(project, name), - Topic: topicName(project, name), - AckDeadlineSeconds: body.AckDeadlineSeconds, - Labels: body.Labels, + if _, err := h.findQueueByName(r, topicShort); err != nil { + writeErr(w, err) + return } - if resp.AckDeadlineSeconds == 0 { - resp.AckDeadlineSeconds = 10 + + ackDeadline := body.AckDeadlineSeconds + if ackDeadline == 0 { + ackDeadline = 10 } - writeJSON(w, http.StatusOK, resp) + h.mu.Lock() + h.subs[name] = &subMeta{topic: topicShort, ackDeadline: ackDeadline, labels: body.Labels} + h.mu.Unlock() + + writeJSON(w, http.StatusOK, subscription{ + Name: subscriptionName(project, name), + Topic: topicName(project, topicShort), + AckDeadlineSeconds: ackDeadline, + Labels: body.Labels, + }) } -func (h *Handler) getSubscription(w http.ResponseWriter, r *http.Request, project, name string) { - q, err := h.findQueueByName(r, name) - if err != nil { - writeErr(w, err) +func (h *Handler) getSubscription(w http.ResponseWriter, _ *http.Request, project, name string) { + h.mu.RLock() + meta, ok := h.subs[name] + h.mu.RUnlock() + + if !ok { + writeError(w, http.StatusNotFound, "NOT_FOUND", "subscription "+name+" not found") return } writeJSON(w, http.StatusOK, subscription{ - Name: subscriptionName(project, q.Name), - Topic: topicName(project, q.Name), - AckDeadlineSeconds: 10, + Name: subscriptionName(project, name), + Topic: topicName(project, meta.topic), + AckDeadlineSeconds: meta.ackDeadline, + Labels: meta.labels, }) } -func (*Handler) deleteSubscription(w http.ResponseWriter, _ *http.Request, _ string) { - // In the driver, deleting the subscription would orphan the topic. Treat - // it as a no-op: real Pub/Sub has no operation that's both safe and useful - // here without modeling subscriptions separately. +func (h *Handler) deleteSubscription(w http.ResponseWriter, _ *http.Request, name string) { + h.mu.Lock() + _, ok := h.subs[name] + delete(h.subs, name) + h.mu.Unlock() + + if !ok { + writeError(w, http.StatusNotFound, "NOT_FOUND", "subscription "+name+" not found") + return + } + + // The subscription is removed from the registry; the topic's queue is left + // intact (real Pub/Sub deletes the subscription, not the topic). writeJSON(w, http.StatusOK, map[string]any{}) } +// subscriptionQueue resolves a subscription to the queue that backs it (its +// topic's queue). Falls back to a same-named queue for subscriptions created +// before registration (legacy tolerance). +func (h *Handler) subscriptionQueue(r *http.Request, name string) (*mqdriver.QueueInfo, error) { + h.mu.RLock() + meta, ok := h.subs[name] + h.mu.RUnlock() + + target := name + if ok { + target = meta.topic + } + + return h.findQueueByName(r, target) +} + func (h *Handler) pull(w http.ResponseWriter, r *http.Request, name string) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed") @@ -362,7 +438,7 @@ func (h *Handler) pull(w http.ResponseWriter, r *http.Request, name string) { return } - q, err := h.findQueueByName(r, name) + q, err := h.subscriptionQueue(r, name) if err != nil { writeErr(w, err) return @@ -381,14 +457,20 @@ func (h *Handler) pull(w http.ResponseWriter, r *http.Request, name string) { return } + // Real Pub/Sub always stamps a publishTime; the driver doesn't retain one, + // so approximate with the delivery time (clients that require a non-empty, + // valid RFC3339 timestamp are satisfied). + publishTime := time.Now().UTC().Format(time.RFC3339) + out := pullResponse{ReceivedMessages: make([]receivedMessage, 0, len(msgs))} for i := range msgs { out.ReceivedMessages = append(out.ReceivedMessages, receivedMessage{ AckID: msgs[i].ReceiptHandle, Message: pubsubMessage{ - MessageID: msgs[i].MessageID, - Data: base64.StdEncoding.EncodeToString([]byte(msgs[i].Body)), - Attributes: msgs[i].Attributes, + MessageID: msgs[i].MessageID, + Data: base64.StdEncoding.EncodeToString([]byte(msgs[i].Body)), + Attributes: msgs[i].Attributes, + PublishTime: publishTime, }, }) } @@ -407,7 +489,7 @@ func (h *Handler) acknowledge(w http.ResponseWriter, r *http.Request, name strin return } - q, err := h.findQueueByName(r, name) + q, err := h.subscriptionQueue(r, name) if err != nil { writeErr(w, err) return diff --git a/server/gcp/pubsub/sdk_roundtrip_test.go b/server/gcp/pubsub/sdk_roundtrip_test.go index fab4a9b9..1f503dea 100644 --- a/server/gcp/pubsub/sdk_roundtrip_test.go +++ b/server/gcp/pubsub/sdk_roundtrip_test.go @@ -109,6 +109,84 @@ func TestSDKPubSubPublishPullAck(t *testing.T) { } } +// TestSDKPubSubSubscriptionMetadata guards the #321 fixes: a subscription may +// have a name distinct from its topic, and its ackDeadline + labels must +// round-trip on Get (not be hardcoded). Delete must also be effective. +func TestSDKPubSubSubscriptionMetadata(t *testing.T) { + svc := newSDKService(t) + ctx := context.Background() + + if _, err := svc.Projects.Topics.Create("projects/demo/topics/events", + &pubsubv1.Topic{}).Context(ctx).Do(); err != nil { + t.Fatalf("Topic.Create: %v", err) + } + + // Distinct subscription name (not "events"). + if _, err := svc.Projects.Subscriptions.Create("projects/demo/subscriptions/billing-sub", + &pubsubv1.Subscription{ + Topic: "projects/demo/topics/events", + AckDeadlineSeconds: 45, + Labels: map[string]string{"team": "fin"}, + }).Context(ctx).Do(); err != nil { + t.Fatalf("Subscription.Create (distinct name): %v", err) + } + + got, err := svc.Projects.Subscriptions.Get("projects/demo/subscriptions/billing-sub").Context(ctx).Do() + if err != nil { + t.Fatalf("Subscription.Get: %v", err) + } + + if !strings.HasSuffix(got.Topic, "/topics/events") { + t.Errorf("topic=%q want .../topics/events", got.Topic) + } + + if got.AckDeadlineSeconds != 45 { + t.Errorf("ackDeadlineSeconds=%d want 45", got.AckDeadlineSeconds) + } + + if got.Labels["team"] != "fin" { + t.Errorf("labels=%v want team=fin", got.Labels) + } + + // Second subscription so List order is observable. Created after billing-sub + // but sorts before it — proving List sorts by name rather than echoing + // insertion or map-iteration order. + if _, err := svc.Projects.Subscriptions.Create("projects/demo/subscriptions/analytics-sub", + &pubsubv1.Subscription{Topic: "projects/demo/topics/events"}).Context(ctx).Do(); err != nil { + t.Fatalf("Subscription.Create (second): %v", err) + } + + // List must return the real subscriptions (distinct names + metadata), not a + // phantom one named after the topic queue, in deterministic sorted order. + list, err := svc.Projects.Subscriptions.List("projects/demo").Context(ctx).Do() + if err != nil { + t.Fatalf("Subscriptions.List: %v", err) + } + + if len(list.Subscriptions) != 2 { + t.Fatalf("List returned %d subs, want 2: %+v", len(list.Subscriptions), list.Subscriptions) + } + + if !strings.HasSuffix(list.Subscriptions[0].Name, "/subscriptions/analytics-sub") || + !strings.HasSuffix(list.Subscriptions[1].Name, "/subscriptions/billing-sub") { + t.Fatalf("List not sorted by name: [%q, %q]", + list.Subscriptions[0].Name, list.Subscriptions[1].Name) + } + + ls := list.Subscriptions[1] // billing-sub carries the metadata under test + if !strings.HasSuffix(ls.Topic, "/topics/events") || ls.AckDeadlineSeconds != 45 { + t.Errorf("List sub metadata wrong: topic=%q ackDeadline=%d", ls.Topic, ls.AckDeadlineSeconds) + } + + if _, err := svc.Projects.Subscriptions.Delete("projects/demo/subscriptions/billing-sub").Context(ctx).Do(); err != nil { + t.Fatalf("Subscription.Delete: %v", err) + } + + if _, err := svc.Projects.Subscriptions.Get("projects/demo/subscriptions/billing-sub").Context(ctx).Do(); err == nil { + t.Fatal("Get after Delete returned nil error, want NotFound") + } +} + func TestSDKPubSubPublishToMissingTopic(t *testing.T) { svc := newSDKService(t) diff --git a/server/gcp/pubsub/types.go b/server/gcp/pubsub/types.go index 844911f9..00160efd 100644 --- a/server/gcp/pubsub/types.go +++ b/server/gcp/pubsub/types.go @@ -32,6 +32,7 @@ type pubsubMessage struct { Attributes map[string]string `json:"attributes,omitempty"` OrderingKey string `json:"orderingKey,omitempty"` MessageID string `json:"messageId,omitempty"` + PublishTime string `json:"publishTime,omitempty"` } type publishResponse struct { diff --git a/server/gcp/secretmanager/sdk_roundtrip_test.go b/server/gcp/secretmanager/sdk_roundtrip_test.go index 3cfac489..f49560b8 100644 --- a/server/gcp/secretmanager/sdk_roundtrip_test.go +++ b/server/gcp/secretmanager/sdk_roundtrip_test.go @@ -153,10 +153,10 @@ func TestSDKSecretManagerVersionsAndAccess(t *testing.T) { t.Fatalf("Versions.List: %v", err) } - // The driver seeds an initial version on create, so two AddVersion calls - // yield three versions. - if len(versions.Versions) != 3 { - t.Fatalf("got %d versions, want 3", len(versions.Versions)) + // GCP secrets.create makes an empty container (no seeded version), so two + // AddVersion calls yield exactly two versions — matching real Secret Manager. + if len(versions.Versions) != 2 { + t.Fatalf("got %d versions, want 2", len(versions.Versions)) } } diff --git a/server/gcp/vertexai/endpoints.go b/server/gcp/vertexai/endpoints.go index 97e81700..36b5a2a2 100644 --- a/server/gcp/vertexai/endpoints.go +++ b/server/gcp/vertexai/endpoints.go @@ -74,8 +74,10 @@ func (h *Handler) endpointAction(w http.ResponseWriter, r *http.Request, p *vPat h.deployModel(w, r, p.name) case "undeployModel": h.undeployModel(w, r, p.name) - case actionGenerateContent, actionStreamGenerateContent: - h.endpointGenerateContent(w, r, p.name) + case actionGenerateContent, actionStreamGenerateContent, actionCountTokens: + // Route the real action through so endpoint countTokens works and stream + // requests aren't collapsed to non-streaming. + h.runGenAI(w, r, p.name, p.action) default: writeError(w, http.StatusNotFound, "notFound", "unknown endpoint action: "+p.action) } diff --git a/server/gcp/vertexai/genai.go b/server/gcp/vertexai/genai.go index f9f6a95e..9f092738 100644 --- a/server/gcp/vertexai/genai.go +++ b/server/gcp/vertexai/genai.go @@ -106,10 +106,6 @@ func (h *Handler) servePublishers(w http.ResponseWriter, r *http.Request) { h.runGenAI(w, r, model, action) } -func (h *Handler) endpointGenerateContent(w http.ResponseWriter, r *http.Request, endpoint string) { - h.runGenAI(w, r, endpoint, "generateContent") -} - // runGenAI dispatches generateContent / countTokens for either a publisher // model path or an endpoint resource name. func (h *Handler) runGenAI(w http.ResponseWriter, r *http.Request, model, action string) { @@ -119,7 +115,7 @@ func (h *Handler) runGenAI(w http.ResponseWriter, r *http.Request, model, action } switch action { - case "generateContent": + case actionGenerateContent: resp, err := h.svc.GenerateContent(r.Context(), model, toDriverRequest(req)) if err != nil { writeCErr(w, err) @@ -140,7 +136,7 @@ func (h *Handler) runGenAI(w http.ResponseWriter, r *http.Request, model, action // single-element array so SDK stream decoders iterate it correctly // (a lone object fails array-decoding / yields zero chunks). writeJSON(w, []map[string]any{generateResponseJSON(resp)}) - case "countTokens": + case actionCountTokens: resp, err := h.svc.CountTokens(r.Context(), model, toDriverRequest(req)) if err != nil { writeCErr(w, err) diff --git a/server/gcp/vertexai/handler.go b/server/gcp/vertexai/handler.go index 439b7f9e..67e73607 100644 --- a/server/gcp/vertexai/handler.go +++ b/server/gcp/vertexai/handler.go @@ -40,6 +40,7 @@ const ( actionCancel = "cancel" actionGenerateContent = "generateContent" actionStreamGenerateContent = "streamGenerateContent" + actionCountTokens = "countTokens" ) // vertexCollections are the resource collections this handler serves. Listed diff --git a/services/compute/driver/driver.go b/services/compute/driver/driver.go index 6d196f39..5a888e6d 100644 --- a/services/compute/driver/driver.go +++ b/services/compute/driver/driver.go @@ -16,6 +16,14 @@ type InstanceConfig struct { // launch time. Principal names the managing service provider. Managed bool Principal string + // OSType ("Linux"/"Windows"), Priority ("Spot"/"Regular"), and LicenseType + // (hybrid-benefit marker) are cost inputs a discoverer prices on; carried + // through to the Instance so Resource Graph / discovery can echo them. Zones + // are the availability zones the instance is placed in. + OSType string + Priority string + LicenseType string + Zones []string } // Instance describes a running virtual machine. @@ -31,6 +39,16 @@ type Instance struct { SecurityGroups []string Tags map[string]string LaunchTime string + // OSType is the guest OS family ("Linux"/"Windows"), when known. + OSType string + // Priority is the provisioning priority ("Spot"/"Regular"), when known — + // used by cost consumers to price interruptible instances. + Priority string + // LicenseType is a bring-your-own-license / hybrid-benefit marker + // ("Windows_Server"/"RHEL_BYOS"), when set. + LicenseType string + // Zones are the availability zones the instance occupies, when known. + Zones []string // Operator carries service-provider managed-resource metadata. It is nil // for ordinary (unmanaged) instances. Operator *OperatorInfo @@ -146,6 +164,14 @@ type VolumeConfig struct { VolumeType string AvailabilityZone string Tags map[string]string + // IOPS / Throughput are the provisioned performance for io2/gp3 and Azure + // Premium SSD v2 / Ultra disks — cost inputs a discoverer prices on. Zero + // means unset (the volume then reports 0, omitted downstream). + IOPS int + Throughput int + // Tier is the performance tier (Azure P10/P4, or a storage tier name), + // echoed as properties.tier / sku.tier for cost tiering. + Tier string } // VolumeInfo describes a block storage volume. @@ -159,6 +185,12 @@ type VolumeInfo struct { Device string CreatedAt string Tags map[string]string + // IOPS is the provisioned IOPS (io2/gp3, Premium/Ultra disks), when set. + IOPS int + // Throughput is the provisioned throughput in MB/s, when set. + Throughput int + // Tier is the performance tier (e.g. Azure P10/P4), when set. + Tier string } // SnapshotConfig describes a snapshot to create. diff --git a/services/cosmospostgresql/driver/driver.go b/services/cosmospostgresql/driver/driver.go new file mode 100644 index 00000000..8dacf06c --- /dev/null +++ b/services/cosmospostgresql/driver/driver.go @@ -0,0 +1,289 @@ +// Package driver defines the portable interface for Azure Cosmos DB for +// PostgreSQL (Microsoft.DBforPostgreSQL/serverGroupsv2), the Citus-based +// distributed-Postgres offering. It is control-plane only — server-group +// clusters and their firewall rules, roles, nodes, configurations, and private +// endpoints — so it is independent of the relational/database drivers. +package driver + +import "context" + +// Provisioning states reported on resources. +const ( + ProvisioningSucceeded = "Succeeded" + ProvisioningCanceled = "Canceled" + ProvisioningFailed = "Failed" +) + +// Server roles within a cluster. +const ( + RoleCoordinator = "Coordinator" + RoleWorker = "Worker" +) + +// Cluster is a Cosmos DB for PostgreSQL server group (serverGroupsv2). +type Cluster struct { + Name string + ResourceGroup string + Location string + Tags map[string]string + ProvisioningState string + State string + + AdministratorLogin string + CitusVersion string + PostgresqlVersion string + CoordinatorServerEdition string + CoordinatorVCores int + CoordinatorStorageQuotaInMb int + CoordinatorEnablePublicIPAccess bool + EnableShardsOnCoordinator bool + NodeServerEdition string + NodeCount int + NodeVCores int + NodeStorageQuotaInMb int + NodeEnablePublicIPAccess bool + EnableHa bool + PreferredPrimaryZone string + MaintenanceWindow *MaintenanceWindow + + // Read-replica linkage. SourceResourceID/SourceLocation are set on a replica; + // ReadReplicas lists the replicas of a primary. + SourceResourceID string + SourceLocation string + ReadReplicas []string +} + +// MaintenanceWindow is the weekly maintenance schedule. +type MaintenanceWindow struct { + CustomWindow string + DayOfWeek int + StartHour int + StartMinute int +} + +// FirewallRule is an IP allow-list entry on a cluster. +type FirewallRule struct { + Name string + ClusterName string + ResourceGroup string + ProvisioningState string + StartIPAddress string + EndIPAddress string +} + +// Role is a Postgres role provisioned on a cluster. +type Role struct { + Name string + ClusterName string + ResourceGroup string + ProvisioningState string +} + +// Server is a node (coordinator or worker) within a cluster. Nodes are derived +// from the cluster's shape and are read-only. +type Server struct { + Name string + ClusterName string + ResourceGroup string + Role string + State string + HaState string + FullyQualifiedDomainName string + AdministratorLogin string + ServerEdition string + VCores int + StorageQuotaInMb int + CitusVersion string + PostgresqlVersion string + EnableHa bool + EnablePublicIPAccess bool + IsReadOnly bool +} + +// RoleGroupValue is one role's value for a cluster-wide configuration. +type RoleGroupValue struct { + Role string + Value string + DefaultValue string + Source string +} + +// Configuration is a cluster-wide server parameter with per-role values. +type Configuration struct { + Name string + ClusterName string + ResourceGroup string + ProvisioningState string + Description string + DataType string + AllowedValues string + RequiresRestart bool + RoleGroups []RoleGroupValue +} + +// ServerConfiguration is a single server-scoped parameter value (coordinator or +// node role group). +type ServerConfiguration struct { + Name string + ClusterName string + ResourceGroup string + ServerName string + ProvisioningState string + Value string + DefaultValue string + Description string + DataType string + AllowedValues string + Source string + RequiresRestart bool +} + +// PrivateEndpointConnection is a private-endpoint connection on a cluster. +type PrivateEndpointConnection struct { + Name string + ClusterName string + ResourceGroup string + ProvisioningState string + GroupIDs []string + PrivateEndpointID string + ConnectionStatus string + ConnectionDesc string + ActionsRequired string +} + +// PrivateLinkResource is a private-link resource (group) exposed by a cluster. +type PrivateLinkResource struct { + Name string + ClusterName string + ResourceGroup string + GroupID string + RequiredMembers []string + RequiredZoneNames []string +} + +// NameAvailability is the result of a CheckNameAvailability call. +type NameAvailability struct { + Name string + Type string + NameAvailable bool + Message string +} + +// CreateClusterConfig is the input to CreateOrUpdateCluster. +type CreateClusterConfig struct { + Name string + ResourceGroup string + Location string + Tags map[string]string + AdministratorLoginPassword string + CitusVersion string + PostgresqlVersion string + CoordinatorServerEdition string + CoordinatorVCores int + CoordinatorStorageQuotaInMb int + CoordinatorEnablePublicIPAccess bool + EnableShardsOnCoordinator bool + NodeServerEdition string + NodeCount int + NodeVCores int + NodeStorageQuotaInMb int + NodeEnablePublicIPAccess bool + EnableHa bool + PreferredPrimaryZone string + MaintenanceWindow *MaintenanceWindow + SourceResourceID string + SourceLocation string +} + +// ClusterPatch carries the mutable fields of an UpdateCluster (PATCH). Nil +// pointers leave the field unchanged. +type ClusterPatch struct { + Tags map[string]string + AdministratorLoginPassword *string + CitusVersion *string + PostgresqlVersion *string + CoordinatorServerEdition *string + CoordinatorVCores *int + CoordinatorStorageQuotaInMb *int + CoordinatorEnablePublicIPAccess *bool + EnableShardsOnCoordinator *bool + NodeServerEdition *string + NodeCount *int + NodeVCores *int + NodeStorageQuotaInMb *int + NodeEnablePublicIPAccess *bool + EnableHa *bool + PreferredPrimaryZone *string + MaintenanceWindow *MaintenanceWindow +} + +// CreateFirewallRuleConfig is the input to CreateOrUpdateFirewallRule. +type CreateFirewallRuleConfig struct { + ResourceGroup string + ClusterName string + Name string + StartIPAddress string + EndIPAddress string +} + +// CreateRoleConfig is the input to CreateRole. +type CreateRoleConfig struct { + ResourceGroup string + ClusterName string + Name string + Password string +} + +// CosmosPostgreSQL is the Azure Cosmos DB for PostgreSQL control plane. +// +//nolint:interfacebloat // mirrors the Microsoft.DBforPostgreSQL/serverGroupsv2 surface. +type CosmosPostgreSQL interface { + // Clusters (server groups). CreateOrUpdateCluster reports whether the cluster + // was created (true) or updated (false) so the ARM layer can return 201/200. + CreateOrUpdateCluster(ctx context.Context, cfg CreateClusterConfig) (cluster *Cluster, created bool, err error) + GetCluster(ctx context.Context, resourceGroup, name string) (*Cluster, error) + ListClustersByResourceGroup(ctx context.Context, resourceGroup string) ([]Cluster, error) + ListClustersBySubscription(ctx context.Context) ([]Cluster, error) + UpdateCluster(ctx context.Context, resourceGroup, name string, patch ClusterPatch) (*Cluster, error) + DeleteCluster(ctx context.Context, resourceGroup, name string) error + RestartCluster(ctx context.Context, resourceGroup, name string) error + StartCluster(ctx context.Context, resourceGroup, name string) error + StopCluster(ctx context.Context, resourceGroup, name string) error + PromoteReadReplica(ctx context.Context, resourceGroup, name string) error + CheckNameAvailability(ctx context.Context, name, typ string) (*NameAvailability, error) + + // Firewall rules. + CreateOrUpdateFirewallRule(ctx context.Context, cfg CreateFirewallRuleConfig) (*FirewallRule, error) + GetFirewallRule(ctx context.Context, resourceGroup, cluster, name string) (*FirewallRule, error) + ListFirewallRules(ctx context.Context, resourceGroup, cluster string) ([]FirewallRule, error) + DeleteFirewallRule(ctx context.Context, resourceGroup, cluster, name string) error + + // Roles. + CreateRole(ctx context.Context, cfg CreateRoleConfig) (*Role, error) + GetRole(ctx context.Context, resourceGroup, cluster, name string) (*Role, error) + ListRoles(ctx context.Context, resourceGroup, cluster string) ([]Role, error) + DeleteRole(ctx context.Context, resourceGroup, cluster, name string) error + + // Servers (nodes, read-only). + GetServer(ctx context.Context, resourceGroup, cluster, name string) (*Server, error) + ListServers(ctx context.Context, resourceGroup, cluster string) ([]Server, error) + + // Configurations. + ListConfigurations(ctx context.Context, resourceGroup, cluster string) ([]Configuration, error) + GetConfiguration(ctx context.Context, resourceGroup, cluster, name string) (*Configuration, error) + GetCoordinatorConfiguration(ctx context.Context, resourceGroup, cluster, name string) (*ServerConfiguration, error) + GetNodeConfiguration(ctx context.Context, resourceGroup, cluster, name string) (*ServerConfiguration, error) + ListServerConfigurations(ctx context.Context, resourceGroup, cluster, server string) ([]ServerConfiguration, error) + UpdateCoordinatorConfiguration(ctx context.Context, resourceGroup, cluster, name, value string) (*ServerConfiguration, error) + UpdateNodeConfiguration(ctx context.Context, resourceGroup, cluster, name, value string) (*ServerConfiguration, error) + + // Private endpoints / links. + CreateOrUpdatePrivateEndpointConnection( + ctx context.Context, resourceGroup, cluster, name, status, description string, + ) (*PrivateEndpointConnection, error) + GetPrivateEndpointConnection(ctx context.Context, resourceGroup, cluster, name string) (*PrivateEndpointConnection, error) + ListPrivateEndpointConnections(ctx context.Context, resourceGroup, cluster string) ([]PrivateEndpointConnection, error) + DeletePrivateEndpointConnection(ctx context.Context, resourceGroup, cluster, name string) error + GetPrivateLinkResource(ctx context.Context, resourceGroup, cluster, name string) (*PrivateLinkResource, error) + ListPrivateLinkResources(ctx context.Context, resourceGroup, cluster string) ([]PrivateLinkResource, error) +} diff --git a/services/cost/cost.go b/services/cost/cost.go index fc9aed4d..dc0b9f9e 100644 --- a/services/cost/cost.go +++ b/services/cost/cost.go @@ -100,6 +100,11 @@ func defaultRates() map[string]float64 { "bigtable:CreateTable": 0.0, "bigtable:CreateBackup": 0.0, + // Azure Cosmos DB for PostgreSQL (Citus): billed per node vCore-hour (+ + // storage). The emulator books a flat proxy charge on cluster create (a + // coordinator plus the worker nodes); child resources are free. + "cosmospostgresql:CreateOrUpdateCluster": 0.52, // flat proxy (~1h of a small coordinator + 2 workers) + // Serverless (per invocation) "serverless:Invoke": 0.0000002, // $0.20 per 1M diff --git a/services/cost/cost_test.go b/services/cost/cost_test.go index 7a9d01ba..73374511 100644 --- a/services/cost/cost_test.go +++ b/services/cost/cost_test.go @@ -75,6 +75,16 @@ func TestTracker_BigtableRates(t *testing.T) { assert.InDelta(t, want, tracker.CostByService()["bigtable"], 1e-9) } +func TestTracker_CosmosPostgreSQLRates(t *testing.T) { + tracker := New() + + // Two clusters priced at the flat node proxy; child resources are free. + tracker.Record("cosmospostgresql", "CreateOrUpdateCluster", 2) + + want := 0.52 * 2 + assert.InDelta(t, want, tracker.CostByService()["cosmospostgresql"], 1e-9) +} + func TestTracker_Record_And_TotalCost(t *testing.T) { tests := []struct { name string diff --git a/services/database/driver/driver.go b/services/database/driver/driver.go index e96e37fe..05b81484 100644 --- a/services/database/driver/driver.go +++ b/services/database/driver/driver.go @@ -94,8 +94,11 @@ type QueryInput struct { Table string IndexName string KeyCondition KeyCondition - Limit int - PageToken string + // Filters is the post-key-condition FilterExpression, applied to items + // that already match the key condition (same semantics as Scan.Filters). + Filters []ScanFilter + Limit int + PageToken string // ExclusiveStartKey selects key-based continuation (DynamoDB-style): // the page starts after the item with these key attributes. Mutually @@ -142,6 +145,24 @@ type IndexInfo struct { } // Database is the interface that database provider implementations must satisfy. +// AccountAttributes are the Cosmos-DB-account cost/identity attributes a real +// Azure `documentdb/databaseaccounts` resource carries but a DynamoDB/Firestore +// table does not. Surfaced through the optional TableAttributes capability. +type AccountAttributes struct { + Kind string // GlobalDocumentDB / MongoDB + OfferType string // databaseAccountOfferType (Standard) + EnableFreeTier bool // free-tier flag (cost) + Capabilities []string // e.g. EnableServerless (cost) +} + +// TableAttributes is an OPTIONAL capability, discovered by type assertion (like +// the storage BucketAttributes capability): a provider whose tables map to a +// richer account resource (Azure Cosmos DB) exposes the account's cost +// attributes. DynamoDB/Firestore don't implement it and contribute nothing. +type TableAttributes interface { + TableAttributes(ctx context.Context, table string) (AccountAttributes, error) +} + type Database interface { CreateTable(ctx context.Context, config TableConfig) error DeleteTable(ctx context.Context, name string) error diff --git a/services/databricks/arm_resources.go b/services/databricks/arm_resources.go new file mode 100644 index 00000000..238e376f --- /dev/null +++ b/services/databricks/arm_resources.go @@ -0,0 +1,251 @@ +package databricks + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/services/databricks/driver" +) + +// This file wraps the extended Microsoft.Databricks ARM surface (issue #209) +// with the same cross-cutting pipeline (do) as the workspace operations. + +// --- Access connectors --- + +// CreateOrUpdateAccessConnector creates or updates an access connector. +func (b *Databricks) CreateOrUpdateAccessConnector( + ctx context.Context, cfg driver.AccessConnectorConfig, +) (*driver.AccessConnector, error) { + out, err := b.do(ctx, "CreateOrUpdateAccessConnector", cfg, func() (any, error) { + return b.driver.CreateOrUpdateAccessConnector(ctx, cfg) + }) + if err != nil { + return nil, err + } + + return out.(*driver.AccessConnector), nil +} + +// GetAccessConnector retrieves an access connector. +func (b *Databricks) GetAccessConnector(ctx context.Context, resourceGroup, name string) (*driver.AccessConnector, error) { + out, err := b.do(ctx, "GetAccessConnector", name, func() (any, error) { + return b.driver.GetAccessConnector(ctx, resourceGroup, name) + }) + if err != nil { + return nil, err + } + + return out.(*driver.AccessConnector), nil +} + +// UpdateAccessConnector applies a PATCH to an access connector. +func (b *Databricks) UpdateAccessConnector( + ctx context.Context, resourceGroup, name string, tags map[string]string, identity *driver.ManagedIdentity, +) (*driver.AccessConnector, error) { + out, err := b.do(ctx, "UpdateAccessConnector", name, func() (any, error) { + return b.driver.UpdateAccessConnector(ctx, resourceGroup, name, tags, identity) + }) + if err != nil { + return nil, err + } + + return out.(*driver.AccessConnector), nil +} + +// DeleteAccessConnector deletes an access connector. +func (b *Databricks) DeleteAccessConnector(ctx context.Context, resourceGroup, name string) error { + _, err := b.do(ctx, "DeleteAccessConnector", name, func() (any, error) { + return nil, b.driver.DeleteAccessConnector(ctx, resourceGroup, name) + }) + + return err +} + +// ListAccessConnectorsByResourceGroup lists access connectors in a resource group. +func (b *Databricks) ListAccessConnectorsByResourceGroup( + ctx context.Context, resourceGroup string, +) ([]driver.AccessConnector, error) { + out, err := b.do(ctx, "ListAccessConnectorsByResourceGroup", resourceGroup, func() (any, error) { + return b.driver.ListAccessConnectorsByResourceGroup(ctx, resourceGroup) + }) + if err != nil { + return nil, err + } + + return out.([]driver.AccessConnector), nil +} + +// ListAccessConnectors lists all access connectors in the subscription. +func (b *Databricks) ListAccessConnectors(ctx context.Context) ([]driver.AccessConnector, error) { + out, err := b.do(ctx, "ListAccessConnectors", nil, func() (any, error) { + return b.driver.ListAccessConnectors(ctx) + }) + if err != nil { + return nil, err + } + + return out.([]driver.AccessConnector), nil +} + +// --- Private endpoint connections --- + +// PutPrivateEndpointConnection creates or updates a workspace PEC. +func (b *Databricks) PutPrivateEndpointConnection( + ctx context.Context, resourceGroup, workspace, name, status, description string, +) (*driver.PrivateEndpointConnection, error) { + out, err := b.do(ctx, "PutPrivateEndpointConnection", name, func() (any, error) { + return b.driver.PutPrivateEndpointConnection(ctx, resourceGroup, workspace, name, status, description) + }) + if err != nil { + return nil, err + } + + return out.(*driver.PrivateEndpointConnection), nil +} + +// GetPrivateEndpointConnection retrieves a workspace PEC. +func (b *Databricks) GetPrivateEndpointConnection( + ctx context.Context, resourceGroup, workspace, name string, +) (*driver.PrivateEndpointConnection, error) { + out, err := b.do(ctx, "GetPrivateEndpointConnection", name, func() (any, error) { + return b.driver.GetPrivateEndpointConnection(ctx, resourceGroup, workspace, name) + }) + if err != nil { + return nil, err + } + + return out.(*driver.PrivateEndpointConnection), nil +} + +// DeletePrivateEndpointConnection deletes a workspace PEC. +func (b *Databricks) DeletePrivateEndpointConnection(ctx context.Context, resourceGroup, workspace, name string) error { + _, err := b.do(ctx, "DeletePrivateEndpointConnection", name, func() (any, error) { + return nil, b.driver.DeletePrivateEndpointConnection(ctx, resourceGroup, workspace, name) + }) + + return err +} + +// ListPrivateEndpointConnections lists a workspace's PECs. +func (b *Databricks) ListPrivateEndpointConnections( + ctx context.Context, resourceGroup, workspace string, +) ([]driver.PrivateEndpointConnection, error) { + out, err := b.do(ctx, "ListPrivateEndpointConnections", workspace, func() (any, error) { + return b.driver.ListPrivateEndpointConnections(ctx, resourceGroup, workspace) + }) + if err != nil { + return nil, err + } + + return out.([]driver.PrivateEndpointConnection), nil +} + +// --- Private link resources --- + +// GetPrivateLinkResource retrieves a workspace private-link resource by group id. +func (b *Databricks) GetPrivateLinkResource( + ctx context.Context, resourceGroup, workspace, groupID string, +) (*driver.GroupIDInformation, error) { + out, err := b.do(ctx, "GetPrivateLinkResource", groupID, func() (any, error) { + return b.driver.GetPrivateLinkResource(ctx, resourceGroup, workspace, groupID) + }) + if err != nil { + return nil, err + } + + return out.(*driver.GroupIDInformation), nil +} + +// ListPrivateLinkResources lists a workspace's private-link resources. +func (b *Databricks) ListPrivateLinkResources( + ctx context.Context, resourceGroup, workspace string, +) ([]driver.GroupIDInformation, error) { + out, err := b.do(ctx, "ListPrivateLinkResources", workspace, func() (any, error) { + return b.driver.ListPrivateLinkResources(ctx, resourceGroup, workspace) + }) + if err != nil { + return nil, err + } + + return out.([]driver.GroupIDInformation), nil +} + +// --- Virtual network peerings --- + +// CreateOrUpdateVNetPeering creates or updates a workspace VNet peering. +func (b *Databricks) CreateOrUpdateVNetPeering( + ctx context.Context, resourceGroup, workspace, name string, cfg driver.VirtualNetworkPeeringConfig, +) (*driver.VirtualNetworkPeering, error) { + out, err := b.do(ctx, "CreateOrUpdateVNetPeering", name, func() (any, error) { + return b.driver.CreateOrUpdateVNetPeering(ctx, resourceGroup, workspace, name, cfg) + }) + if err != nil { + return nil, err + } + + return out.(*driver.VirtualNetworkPeering), nil +} + +// GetVNetPeering retrieves a workspace VNet peering. +func (b *Databricks) GetVNetPeering( + ctx context.Context, resourceGroup, workspace, name string, +) (*driver.VirtualNetworkPeering, error) { + out, err := b.do(ctx, "GetVNetPeering", name, func() (any, error) { + return b.driver.GetVNetPeering(ctx, resourceGroup, workspace, name) + }) + if err != nil { + return nil, err + } + + return out.(*driver.VirtualNetworkPeering), nil +} + +// DeleteVNetPeering deletes a workspace VNet peering. +func (b *Databricks) DeleteVNetPeering(ctx context.Context, resourceGroup, workspace, name string) error { + _, err := b.do(ctx, "DeleteVNetPeering", name, func() (any, error) { + return nil, b.driver.DeleteVNetPeering(ctx, resourceGroup, workspace, name) + }) + + return err +} + +// ListVNetPeerings lists a workspace's VNet peerings. +func (b *Databricks) ListVNetPeerings( + ctx context.Context, resourceGroup, workspace string, +) ([]driver.VirtualNetworkPeering, error) { + out, err := b.do(ctx, "ListVNetPeerings", workspace, func() (any, error) { + return b.driver.ListVNetPeerings(ctx, resourceGroup, workspace) + }) + if err != nil { + return nil, err + } + + return out.([]driver.VirtualNetworkPeering), nil +} + +// --- Outbound network dependencies & operations --- + +// ListOutboundNetworkDependencies lists a workspace's outbound network dependencies. +func (b *Databricks) ListOutboundNetworkDependencies( + ctx context.Context, resourceGroup, workspace string, +) ([]driver.OutboundEndpoint, error) { + out, err := b.do(ctx, "ListOutboundNetworkDependencies", workspace, func() (any, error) { + return b.driver.ListOutboundNetworkDependencies(ctx, resourceGroup, workspace) + }) + if err != nil { + return nil, err + } + + return out.([]driver.OutboundEndpoint), nil +} + +// ListOperations lists the Microsoft.Databricks provider operations. +func (b *Databricks) ListOperations(ctx context.Context) ([]driver.Operation, error) { + out, err := b.do(ctx, "ListOperations", nil, func() (any, error) { + return b.driver.ListOperations(ctx) + }) + if err != nil { + return nil, err + } + + return out.([]driver.Operation), nil +} diff --git a/services/databricks/driver/arm_resources.go b/services/databricks/driver/arm_resources.go new file mode 100644 index 00000000..3323a49b --- /dev/null +++ b/services/databricks/driver/arm_resources.go @@ -0,0 +1,177 @@ +package driver + +import "context" + +// This file extends the Microsoft.Databricks ARM control-plane surface beyond +// workspaces (issue #209): access connectors, private endpoint connections, +// private link resources, virtual-network peerings, outbound network +// dependencies, and the provider operations list. +// +// These are modeled store-and-echo: the ARM wire shapes round-trip faithfully +// over the real armdatabricks SDK, but the underlying networking side effects +// (private-endpoint approval on the platform side, actual VNet peering, live +// outbound reachability) are not simulated — see docs/services.md. + +// Peering provisioning/state values. +const ( + PeeringStateInitiated = "Initiated" + PeeringStateConnected = "Connected" +) + +// ManagedIdentity models an ARM managed service identity on an access connector. +type ManagedIdentity struct { + // Type is one of "None", "SystemAssigned", "UserAssigned", + // "SystemAssigned,UserAssigned". + Type string + // UserAssigned holds the user-assigned identity resource IDs (keys of the + // ARM userAssignedIdentities map). + UserAssigned []string + // PrincipalID/TenantID are synthesized for a system-assigned identity. + PrincipalID string + TenantID string +} + +// AccessConnector is a Microsoft.Databricks/accessConnectors resource. +type AccessConnector struct { + ID string + Name string + ResourceGroup string + Location string + Tags map[string]string + Identity *ManagedIdentity + ProvisioningState string + CreatedAt string +} + +// AccessConnectorConfig is the createOrUpdate input for an access connector. +type AccessConnectorConfig struct { + Name string + ResourceGroup string + Location string + Tags map[string]string + Identity *ManagedIdentity +} + +// PrivateEndpointConnection is a workspace private-endpoint connection. +type PrivateEndpointConnection struct { + ID string + Name string + GroupIDs []string + PrivateEndpointID string + Status string // Pending | Approved | Rejected | Disconnected + Description string + ActionsRequired string + ProvisioningState string +} + +// GroupIDInformation is a workspace private-link resource (a group id and its +// required members / DNS zones). +type GroupIDInformation struct { + ID string + Name string + GroupID string + RequiredMembers []string + RequiredZoneNames []string +} + +// AddressSpace is a list of CIDR address prefixes. +type AddressSpace struct { + AddressPrefixes []string +} + +// VirtualNetworkPeering is a workspace virtualNetworkPeerings resource. +type VirtualNetworkPeering struct { + ID string + Name string + AllowForwardedTraffic bool + AllowGatewayTransit bool + AllowVirtualNetworkAccess bool + UseRemoteGateways bool + DatabricksVNetID string + DatabricksAddressSpace *AddressSpace + RemoteVNetID string + RemoteAddressSpace *AddressSpace + PeeringState string + ProvisioningState string +} + +// VirtualNetworkPeeringConfig is the createOrUpdate input for a peering. +type VirtualNetworkPeeringConfig struct { + AllowForwardedTraffic bool + AllowGatewayTransit bool + AllowVirtualNetworkAccess bool + UseRemoteGateways bool + DatabricksVNetID string + DatabricksAddressSpace *AddressSpace + RemoteVNetID string + RemoteAddressSpace *AddressSpace +} + +// OutboundEndpoint is one category of outbound network dependency for a +// workspace and the domains it reaches. +type OutboundEndpoint struct { + Category string + Endpoints []EndpointDependency +} + +// EndpointDependency is a domain and the ports the workspace connects to. +type EndpointDependency struct { + DomainName string + EndpointDetails []EndpointDetail +} + +// EndpointDetail is a single reachable port for a domain. +type EndpointDetail struct { + Port int32 +} + +// Operation is one entry in the provider operations list. +type Operation struct { + Name string + Provider string + Resource string + Operation string + Description string + IsDataAction bool +} + +// ARMResources is the extended Microsoft.Databricks ARM surface (issue #209). +// It is composed into Databricks so a single driver value serves the whole +// provider namespace. +type ARMResources interface { + // Access connectors (top-level Microsoft.Databricks/accessConnectors). + CreateOrUpdateAccessConnector(ctx context.Context, cfg AccessConnectorConfig) (*AccessConnector, error) + GetAccessConnector(ctx context.Context, resourceGroup, name string) (*AccessConnector, error) + UpdateAccessConnector( + ctx context.Context, resourceGroup, name string, tags map[string]string, identity *ManagedIdentity, + ) (*AccessConnector, error) + DeleteAccessConnector(ctx context.Context, resourceGroup, name string) error + ListAccessConnectorsByResourceGroup(ctx context.Context, resourceGroup string) ([]AccessConnector, error) + ListAccessConnectors(ctx context.Context) ([]AccessConnector, error) + + // Private endpoint connections (workspaces/{w}/privateEndpointConnections). + PutPrivateEndpointConnection( + ctx context.Context, resourceGroup, workspace, name, status, description string, + ) (*PrivateEndpointConnection, error) + GetPrivateEndpointConnection(ctx context.Context, resourceGroup, workspace, name string) (*PrivateEndpointConnection, error) + DeletePrivateEndpointConnection(ctx context.Context, resourceGroup, workspace, name string) error + ListPrivateEndpointConnections(ctx context.Context, resourceGroup, workspace string) ([]PrivateEndpointConnection, error) + + // Private link resources (workspaces/{w}/privateLinkResources). + GetPrivateLinkResource(ctx context.Context, resourceGroup, workspace, groupID string) (*GroupIDInformation, error) + ListPrivateLinkResources(ctx context.Context, resourceGroup, workspace string) ([]GroupIDInformation, error) + + // Virtual network peerings (workspaces/{w}/virtualNetworkPeerings). + CreateOrUpdateVNetPeering( + ctx context.Context, resourceGroup, workspace, name string, cfg VirtualNetworkPeeringConfig, + ) (*VirtualNetworkPeering, error) + GetVNetPeering(ctx context.Context, resourceGroup, workspace, name string) (*VirtualNetworkPeering, error) + DeleteVNetPeering(ctx context.Context, resourceGroup, workspace, name string) error + ListVNetPeerings(ctx context.Context, resourceGroup, workspace string) ([]VirtualNetworkPeering, error) + + // Outbound network dependencies (workspaces/{w}/outboundNetworkDependenciesEndpoints). + ListOutboundNetworkDependencies(ctx context.Context, resourceGroup, workspace string) ([]OutboundEndpoint, error) + + // Provider operations (/providers/Microsoft.Databricks/operations). + ListOperations(ctx context.Context) ([]Operation, error) +} diff --git a/services/databricks/driver/driver.go b/services/databricks/driver/driver.go index 17b0bfe9..14b6dc9e 100644 --- a/services/databricks/driver/driver.go +++ b/services/databricks/driver/driver.go @@ -40,7 +40,9 @@ type Workspace struct { } // Databricks is the interface that workspace service implementations must -// satisfy. +// satisfy. It also embeds the extended Microsoft.Databricks ARM surface +// (access connectors, private endpoint connections, private link resources, +// VNet peerings, outbound network dependencies, operations — issue #209). type Databricks interface { CreateWorkspace(ctx context.Context, cfg WorkspaceConfig) (*Workspace, error) GetWorkspace(ctx context.Context, resourceGroup, name string) (*Workspace, error) @@ -48,4 +50,6 @@ type Databricks interface { UpdateWorkspaceTags(ctx context.Context, resourceGroup, name string, tags map[string]string) (*Workspace, error) ListWorkspacesByResourceGroup(ctx context.Context, resourceGroup string) ([]Workspace, error) ListWorkspaces(ctx context.Context) ([]Workspace, error) + + ARMResources } diff --git a/services/kubernetes/admission.go b/services/kubernetes/admission.go new file mode 100644 index 00000000..bfcb044f --- /dev/null +++ b/services/kubernetes/admission.go @@ -0,0 +1,440 @@ +package kubernetes + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "sort" + "time" + + jsonpatch "gopkg.in/evanphx/json-patch.v4" + admissionv1 "k8s.io/api/admission/v1" + admissionregv1 "k8s.io/api/admissionregistration/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +// Operation values sent on the AdmissionRequest — the two this server ever +// issues a write for (registryDelete has no admission call site: deletes +// aren't in scope for this pass). +const ( + opCreate = "CREATE" + opUpdate = "UPDATE" +) + +const ( + admissionReviewAPIVersion = "admission.k8s.io/v1" + admissionReviewKind = "AdmissionReview" + + // defaultAdmissionTimeout bounds both the outbound HTTP call to a webhook + // and the ClusterState's default *http.Client when none was injected via + // APIServer.SetAdmissionHTTPClient. + defaultAdmissionTimeout = 5 * time.Second + + // admissionDeniedStatusCode is the HTTP status used for a denial whose + // AdmissionResponse.status.code was left unset — matching real apiserver, + // which defaults a webhook denial to 403 Forbidden. + admissionDeniedStatusCode = http.StatusForbidden +) + +// gvr returns the GroupVersionResource a registry-backed kind is served +// under — what a webhook's rules[] and an AdmissionRequest match against. +func (d *resourceDef) gvr() metav1.GroupVersionResource { + return metav1.GroupVersionResource{Group: d.group, Version: d.version, Resource: d.plural} +} + +// gvrPods and gvrDeployments are the GVRs for the two typed (non-registry) +// write paths that run admission — core/v1 Pods and apps/v1 Deployments. +func gvrPods() metav1.GroupVersionResource { + return metav1.GroupVersionResource{Version: apiVersionV1, Resource: "pods"} +} + +func gvrDeployments() metav1.GroupVersionResource { + return metav1.GroupVersionResource{Group: apiGroupApps, Version: apiVersionV1, Resource: resourceDeployments} +} + +// webhookCall is the subset of a Mutating/ValidatingWebhook this server acts +// on — the two webhook kinds are structurally identical here bar their Go +// type, so both extraction paths normalize into this. +type webhookCall struct { + name string + url string + failurePolicy admissionregv1.FailurePolicyType + rules []admissionregv1.RuleWithOperations +} + +// matches reports whether op+gvr falls under any of the webhook's rules. +func (c webhookCall) matches(op string, gvr metav1.GroupVersionResource) bool { + for i := range c.rules { + if admissionRuleMatches(&c.rules[i], op, gvr) { + return true + } + } + + return false +} + +func admissionRuleMatches(rule *admissionregv1.RuleWithOperations, op string, gvr metav1.GroupVersionResource) bool { + return operationMatches(rule.Operations, op) && + stringMatches(rule.APIGroups, gvr.Group) && + stringMatches(rule.APIVersions, gvr.Version) && + stringMatches(rule.Resources, gvr.Resource) +} + +const admissionWildcard = "*" + +func operationMatches(ops []admissionregv1.OperationType, op string) bool { + for _, o := range ops { + if o == admissionregv1.OperationAll || string(o) == op { + return true + } + } + + return false +} + +func stringMatches(values []string, want string) bool { + for _, v := range values { + if v == admissionWildcard || v == want { + return true + } + } + + return false +} + +func failurePolicyOrDefault(p *admissionregv1.FailurePolicyType) admissionregv1.FailurePolicyType { + if p == nil { + // Real apiserver defaults an unset failurePolicy to Fail. + return admissionregv1.Fail + } + + return *p +} + +func webhookURL(cc admissionregv1.WebhookClientConfig) string { + if cc.URL == nil { + return "" + } + + return *cc.URL +} + +// rawWebhook is the JSON shape shared by admissionregistration/v1's +// MutatingWebhook and ValidatingWebhook — decoding into this one local type +// (rather than the full typed {Mutating,Validating}WebhookConfiguration) +// lets webhookCallsFromConfig serve both kinds without duplicating the +// extraction logic. +type rawWebhook struct { + Name string `json:"name"` + ClientConfig admissionregv1.WebhookClientConfig `json:"clientConfig"` + Rules []admissionregv1.RuleWithOperations `json:"rules"` + FailurePolicy *admissionregv1.FailurePolicyType `json:"failurePolicy"` +} + +// webhookCallsFromConfig decodes the webhooks[] of one stored +// {Mutating,Validating}WebhookConfiguration into webhookCalls this server can +// invoke. Service-ref clientConfig is not supported — only a direct +// clientConfig.url, per this phase's scope. +func webhookCallsFromConfig(cfg *unstructured.Unstructured) []webhookCall { + items, found, err := unstructured.NestedSlice(cfg.Object, "webhooks") + if err != nil || !found { + return nil + } + + out := make([]webhookCall, 0, len(items)) + + for _, item := range items { + m, ok := item.(map[string]any) + if !ok { + continue + } + + var raw rawWebhook + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(m, &raw); err != nil { + continue + } + + out = append(out, webhookCall{ + name: raw.Name, url: webhookURL(raw.ClientConfig), + failurePolicy: failurePolicyOrDefault(raw.FailurePolicy), rules: raw.Rules, + }) + } + + return out +} + +// matchingWebhooksLocked reads the registry store for plural (one of the two +// webhook config kinds) and returns every webhookCall whose rules match +// op+gvr, in stored-key order. Callers hold s.mu. +func (s *ClusterState) matchingWebhooksLocked(plural, op string, gvr metav1.GroupVersionResource) []webhookCall { + store := s.reg.stores[regKey(apiGroupAdmissionRegistration, apiVersionV1, plural)] + if store == nil { + return nil + } + + keys := make([]string, 0, len(store.items)) + for k := range store.items { + keys = append(keys, k) + } + + sort.Strings(keys) + + var out []webhookCall + + for _, k := range keys { + for _, wh := range webhookCallsFromConfig(store.items[k]) { + if wh.matches(op, gvr) { + out = append(out, wh) + } + } + } + + return out +} + +// runAdmission runs every matching mutating webhook (applying its patch, if +// any, before the next one sees the object) and then every matching +// validating webhook against the final object. Callers hold s.mu — the +// outbound HTTP call therefore runs with the cluster lock held, which is an +// accepted simplification for an opt-in, deliberately non-concurrent mock. +// +// Returns the mutated object (nil if no mutating webhook patched it) and, on +// a denial or an unrecoverable failurePolicy=Fail error, the Status to +// return to the client instead of persisting the write. +func (s *ClusterState) runAdmission( + op string, gvr metav1.GroupVersionResource, obj *unstructured.Unstructured, +) (*unstructured.Unstructured, *metav1.Status) { + current := obj + mutated := false + + for _, wh := range s.matchingWebhooksLocked(pluralMutatingWebhooks, op, gvr) { + resp, denied := s.callWebhook(wh, op, gvr, current) + if denied != nil { + return nil, denied + } + + if patched, ok := applyAdmissionPatch(current, resp); ok { + current = patched + mutated = true + } + } + + for _, wh := range s.matchingWebhooksLocked(pluralValidatingWebhooks, op, gvr) { + if _, denied := s.callWebhook(wh, op, gvr, current); denied != nil { + return nil, denied + } + } + + if mutated { + return current, nil + } + + return nil, nil +} + +// callWebhook invokes one webhook and folds its failurePolicy into the +// result: a transport/decode failure under Ignore is swallowed (resp=nil, +// denied=nil); under Fail (the default) it becomes a denial. An explicit +// allowed=false response always becomes a denial regardless of failurePolicy. +func (s *ClusterState) callWebhook( + wh webhookCall, op string, gvr metav1.GroupVersionResource, obj *unstructured.Unstructured, +) (*admissionv1.AdmissionResponse, *metav1.Status) { + resp, ok := s.postAdmissionReview(wh, op, gvr, obj) + if !ok { + if wh.failurePolicy == admissionregv1.Ignore { + return nil, nil + } + + return nil, &metav1.Status{ + TypeMeta: metav1.TypeMeta{Kind: "Status", APIVersion: "v1"}, + Status: metav1.StatusFailure, + Code: http.StatusInternalServerError, + Reason: metav1.StatusReasonInternalError, + Message: "k8s api: admission webhook " + wh.name + " call failed", + } + } + + if !resp.Allowed { + return resp, deniedStatus(wh.name, resp.Result) + } + + return resp, nil +} + +// deniedStatus builds the Status returned to the client for a webhook +// denial, filling in the parts a webhook's AdmissionResponse.status left +// unset (real apiserver does the same). +func deniedStatus(name string, result *metav1.Status) *metav1.Status { + out := &metav1.Status{TypeMeta: metav1.TypeMeta{Kind: "Status", APIVersion: "v1"}, Status: metav1.StatusFailure} + if result != nil { + out = result.DeepCopy() + } + + if out.Code == 0 { + out.Code = admissionDeniedStatusCode + } + + if out.Reason == "" { + out.Reason = metav1.StatusReasonForbidden + } + + if out.Message == "" { + out.Message = "k8s api: admission webhook " + name + " denied the request" + } + + return out +} + +// applyAdmissionPatch applies a mutating webhook's JSONPatch (RFC 6902) +// response to cur, returning ok=false if the response carried no patch or +// the patch didn't apply cleanly (treated as a no-op mutation rather than a +// hard failure — only allowed/denied is contractual). +func applyAdmissionPatch(cur *unstructured.Unstructured, resp *admissionv1.AdmissionResponse) (*unstructured.Unstructured, bool) { + if resp == nil || len(resp.Patch) == 0 || resp.PatchType == nil || *resp.PatchType != admissionv1.PatchTypeJSONPatch { + return nil, false + } + + curBytes, err := json.Marshal(cur.Object) + if err != nil { + return nil, false + } + + p, err := jsonpatch.DecodePatch(resp.Patch) + if err != nil { + return nil, false + } + + merged, err := p.Apply(curBytes) + if err != nil { + return nil, false + } + + out := &unstructured.Unstructured{} + if err := out.UnmarshalJSON(merged); err != nil { + return nil, false + } + + return out, true +} + +// postAdmissionReview POSTs an AdmissionReview request for obj to wh.url and +// decodes the response. ok=false covers every way the call didn't produce a +// usable AdmissionResponse (no URL configured, transport error, bad JSON) — +// the caller applies failurePolicy to decide what that means. +func (s *ClusterState) postAdmissionReview( + wh webhookCall, op string, gvr metav1.GroupVersionResource, obj *unstructured.Unstructured, +) (*admissionv1.AdmissionResponse, bool) { + if wh.url == "" { + return nil, false + } + + body, err := json.Marshal(buildAdmissionReview(op, gvr, obj)) + if err != nil { + return nil, false + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultAdmissionTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, wh.url, bytes.NewReader(body)) + if err != nil { + return nil, false + } + + req.Header.Set("Content-Type", contentTypeJSON) + + httpResp, err := s.admissionClient.Do(req) + if err != nil { + return nil, false + } + + defer httpResp.Body.Close() + + respBody, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, false + } + + var review admissionv1.AdmissionReview + if err := json.Unmarshal(respBody, &review); err != nil || review.Response == nil { + return nil, false + } + + return review.Response, true +} + +func buildAdmissionReview(op string, gvr metav1.GroupVersionResource, obj *unstructured.Unstructured) *admissionv1.AdmissionReview { + raw, _ := json.Marshal(obj.Object) + + return &admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{APIVersion: admissionReviewAPIVersion, Kind: admissionReviewKind}, + Request: &admissionv1.AdmissionRequest{ + UID: types.UID(newUID()), + Kind: metav1.GroupVersionKind{Group: gvr.Group, Version: gvr.Version, Kind: obj.GetKind()}, + Resource: gvr, + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + Operation: admissionv1.Operation(op), + Object: runtime.RawExtension{Raw: raw}, + }, + } +} + +// admit is the call-site entry point: it runs the admission chain (a no-op +// when admission is disabled, the default) and, on a denial, writes the +// Status response itself and reports handled=true so the caller aborts the +// write without persisting anything. obj is any pointer accepted by +// runtime.DefaultUnstructuredConverter (a typed *corev1.Pod/*appsv1.Deployment, +// or a *unstructured.Unstructured for the registry path) and is mutated in +// place when a mutating webhook returned a patch. Callers hold s.mu. +func (s *ClusterState) admit(w http.ResponseWriter, op string, gvr metav1.GroupVersionResource, obj any) bool { + if !s.admissionEnabled { + return false + } + + u, ok := toAdmissionUnstructured(obj) + if !ok { + return false + } + + mutated, denied := s.runAdmission(op, gvr, u) + if denied != nil { + writeStatus(w, int(denied.Code), denied.Reason, denied.Message) + + return true + } + + if mutated != nil { + applyAdmissionResult(obj, mutated) + } + + return false +} + +func toAdmissionUnstructured(obj any) (*unstructured.Unstructured, bool) { + if u, ok := obj.(*unstructured.Unstructured); ok { + return u, true + } + + m, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return nil, false + } + + return &unstructured.Unstructured{Object: m}, true +} + +func applyAdmissionResult(obj any, mutated *unstructured.Unstructured) { + if u, ok := obj.(*unstructured.Unstructured); ok { + *u = *mutated + + return + } + + _ = runtime.DefaultUnstructuredConverter.FromUnstructured(mutated.Object, obj) +} diff --git a/services/kubernetes/admission_test.go b/services/kubernetes/admission_test.go new file mode 100644 index 00000000..09b26857 --- /dev/null +++ b/services/kubernetes/admission_test.go @@ -0,0 +1,168 @@ +// Admission webhook tests: a validating webhook served over TLS by +// httptest.NewTLSServer denies a Pod create when admission is enabled, and is +// never invoked at all when it's disabled (the default) — the feature must +// not change existing behavior unless explicitly turned on. + +package kubernetes_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + admissionregv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stackshy/cloudemu/v2/services/kubernetes" +) + +const admissionDenyMessage = "pods are not allowed in this test cluster" + +// newDenyingWebhook returns a TLS test server that answers every +// AdmissionReview with allowed=false and admissionDenyMessage, and a counter +// of how many times it was called. +func newDenyingWebhook(t *testing.T) (*httptest.Server, *int32) { + t.Helper() + + var calls int32 + + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + + var review admissionv1.AdmissionReview + if err := json.NewDecoder(r.Body).Decode(&review); err != nil { + t.Errorf("webhook: decode request: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(&admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{APIVersion: "admission.k8s.io/v1", Kind: "AdmissionReview"}, + Response: &admissionv1.AdmissionResponse{ + UID: review.Request.UID, + Allowed: false, + Result: &metav1.Status{Message: admissionDenyMessage, Code: http.StatusForbidden, Reason: metav1.StatusReasonForbidden}, + }, + }) + })) + t.Cleanup(ts.Close) + + return ts, &calls +} + +// registerDenyingValidatingWebhook stores a ValidatingWebhookConfiguration +// whose single webhook rule matches CREATE on core/v1 pods and points at +// webhookURL. +func registerDenyingValidatingWebhook(t *testing.T, base, webhookURL string) { + t.Helper() + + fail := admissionregv1.Fail + sideEffects := admissionregv1.SideEffectClassNone + + cfg := &admissionregv1.ValidatingWebhookConfiguration{ + TypeMeta: metav1.TypeMeta{Kind: "ValidatingWebhookConfiguration", APIVersion: "admissionregistration.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "deny-pods"}, + Webhooks: []admissionregv1.ValidatingWebhook{{ + Name: "deny.pods.cloudemu.test", + ClientConfig: admissionregv1.WebhookClientConfig{URL: &webhookURL}, + Rules: []admissionregv1.RuleWithOperations{{ + Operations: []admissionregv1.OperationType{admissionregv1.Create}, + Rule: admissionregv1.Rule{ + APIGroups: []string{""}, + APIVersions: []string{"v1"}, + Resources: []string{"pods"}, + }, + }}, + FailurePolicy: &fail, + SideEffects: &sideEffects, + AdmissionReviewVersions: []string{"v1"}, + }}, + } + + resp := do(t, http.MethodPost, base+"/apis/admissionregistration.k8s.io/v1/validatingwebhookconfigurations", mustJSON(t, cfg)) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("register validating webhook config: got %d, want 201", resp.StatusCode) + } +} + +func testPodBody(t *testing.T, name string) []byte { + t.Helper() + + return mustJSON(t, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "c", Image: "nginx"}}}, + }) +} + +func TestAdmission_ValidatingWebhookDeniesCreate(t *testing.T) { + webhook, calls := newDenyingWebhook(t) + + api := kubernetes.NewAPIServer() + api.SetAdmissionEnabled(true) + api.SetAdmissionHTTPClient(webhook.Client()) + + uid, _ := api.RegisterCluster() + + server := httptest.NewServer(api) + t.Cleanup(server.Close) + + base := server.URL + "/k8s/" + uid + + registerDenyingValidatingWebhook(t, base, webhook.URL) + + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/pods", testPodBody(t, "blocked")) + if resp.StatusCode < http.StatusBadRequest { + t.Fatalf("create status: got %d, want 4xx (denied)", resp.StatusCode) + } + + var status metav1.Status + mustDecode(t, resp.Body, &status) + + if status.Message != admissionDenyMessage { + t.Fatalf("denial message: got %q, want %q", status.Message, admissionDenyMessage) + } + + if got := atomic.LoadInt32(calls); got != 1 { + t.Fatalf("webhook calls: got %d, want 1", got) + } + + // The Pod must not have been persisted. + resp2 := do(t, http.MethodGet, base+"/api/v1/namespaces/default/pods/blocked", nil) + defer resp2.Body.Close() + + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("get after denied create: got %d, want 404", resp2.StatusCode) + } +} + +func TestAdmission_DisabledByDefaultSkipsWebhook(t *testing.T) { + webhook, calls := newDenyingWebhook(t) + + // Admission is left disabled (the default) — SetAdmissionEnabled is never + // called, so the webhook config below is stored but never invoked. + api := kubernetes.NewAPIServer() + uid, _ := api.RegisterCluster() + + server := httptest.NewServer(api) + t.Cleanup(server.Close) + + base := server.URL + "/k8s/" + uid + + registerDenyingValidatingWebhook(t, base, webhook.URL) + + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/pods", testPodBody(t, "allowed")) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create status: got %d, want 201 (admission disabled, webhook must not run)", resp.StatusCode) + } + + if got := atomic.LoadInt32(calls); got != 0 { + t.Fatalf("webhook calls: got %d, want 0 (admission disabled)", got) + } +} diff --git a/services/kubernetes/apiserver.go b/services/kubernetes/apiserver.go index bc8d6b1f..783dbc2c 100644 --- a/services/kubernetes/apiserver.go +++ b/services/kubernetes/apiserver.go @@ -18,6 +18,8 @@ import ( "net/http" "strings" "sync" + + "github.com/stackshy/cloudemu/v2/config" ) // pathPrefix is the URL segment that namespaces every cluster's data plane. @@ -37,11 +39,35 @@ type APIServer struct { mu sync.RWMutex clusters map[string]*ClusterState baseURL string + // clock is handed to every ClusterState registered after it is set, so a + // FakeClock injected before RegisterCluster makes the data plane's + // timestamps deterministic. Defaults to config.RealClock. + clock config.Clock + + // admissionEnabled and admissionClient configure the opt-in admission + // webhook chain (see SetAdmissionEnabled, admission.go). Captured onto + // each ClusterState at RegisterCluster time, the same way the rest of + // this server's options are threaded down. + admissionEnabled bool + admissionClient *http.Client } // NewAPIServer returns an empty APIServer with no registered clusters. func NewAPIServer() *APIServer { - return &APIServer{clusters: make(map[string]*ClusterState)} + return &APIServer{clusters: make(map[string]*ClusterState), clock: config.RealClock{}} +} + +// SetClock sets the clock handed to ClusterStates registered from now on. Wire +// a config.FakeClock before RegisterCluster to make all data-plane timestamps +// deterministic. +func (s *APIServer) SetClock(c config.Clock) { + if c == nil { + return + } + + s.mu.Lock() + s.clock = c + s.mu.Unlock() } // RegisterCluster allocates fresh state for a new cluster and returns its @@ -49,15 +75,38 @@ func NewAPIServer() *APIServer { // server URL — kubeconfig "server" becomes "/k8s/". func (s *APIServer) RegisterCluster() (string, *ClusterState) { uid := newUID() - state := newClusterState() s.mu.Lock() + state := newClusterState(s.clock, s.admissionEnabled, s.admissionClient) s.clusters[uid] = state s.mu.Unlock() return uid, state } +// SetAdmissionEnabled turns the admission webhook chain on or off for +// clusters registered after the call. Default is false: MutatingWebhook/ +// ValidatingWebhookConfiguration objects still store and round-trip through +// `kubectl apply`, but are never invoked — a create/update/patch behaves +// exactly as before. Outbound HTTPS calls to webhook endpoints fight +// cloudemu's zero-network, deterministic-by-default pillar, so this is +// opt-in rather than derived from the presence of webhook configs. +func (s *APIServer) SetAdmissionEnabled(enabled bool) { + s.mu.Lock() + s.admissionEnabled = enabled + s.mu.Unlock() +} + +// SetAdmissionHTTPClient overrides the HTTP client used to call admission +// webhooks for clusters registered after the call. Tests use this to inject +// a client that trusts an httptest.NewTLSServer's certificate instead of +// wiring up real TLS trust for a fake webhook endpoint. +func (s *APIServer) SetAdmissionHTTPClient(c *http.Client) { + s.mu.Lock() + s.admissionClient = c + s.mu.Unlock() +} + // DeregisterCluster removes a cluster's state. Called by control-plane // handlers on DeleteCluster. Idempotent. func (s *APIServer) DeregisterCluster(uid string) { diff --git a/services/kubernetes/configmap.go b/services/kubernetes/configmap.go index 30263a55..758b4fef 100644 --- a/services/kubernetes/configmap.go +++ b/services/kubernetes/configmap.go @@ -4,7 +4,6 @@ import ( "net/http" "sort" "strings" - "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -40,7 +39,7 @@ func (s *ClusterState) serveConfigMaps(w http.ResponseWriter, r *http.Request, r return } - s.listConfigMapsAllNamespaces(w) + s.listConfigMapsAllNamespaces(w, r) return } @@ -69,7 +68,7 @@ func (s *ClusterState) serveConfigMapCollection(w http.ResponseWriter, r *http.R return } - s.listConfigMaps(w, namespace) + s.listConfigMaps(w, r, namespace) case http.MethodPost: s.createConfigMap(w, r, namespace) default: @@ -95,7 +94,7 @@ func (s *ClusterState) serveConfigMapItem(w http.ResponseWriter, r *http.Request case http.MethodPatch: s.patchConfigMap(w, r, namespace, name) case http.MethodDelete: - s.deleteConfigMap(w, namespace, name) + s.deleteConfigMap(w, r, namespace, name) default: writeMethodNotAllowed(w, "k8s api: configmap item: method not allowed: "+r.Method) } @@ -128,33 +127,49 @@ func (s *ClusterState) createConfigMap(w http.ResponseWriter, r *http.Request, n return } - stamp(&in.ObjectMeta) + s.stamp(&in.ObjectMeta) in.TypeMeta = metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"} + if isDryRun(r) { + writeJSON(w, http.StatusCreated, &in) + + return + } + cm := in s.configMaps[key] = &cm s.wConfigMaps.publish(EventAdded, namespace, *cm.DeepCopy()) writeJSON(w, http.StatusCreated, &cm) } -func (s *ClusterState) listConfigMaps(w http.ResponseWriter, namespace string) { +func (s *ClusterState) listConfigMaps(w http.ResponseWriter, r *http.Request, namespace string) { s.mu.RLock() defer s.mu.RUnlock() - items := s.collectConfigMapsLocked(namespace) + items, cont, ok := listPage(s.collectConfigMapsLocked(namespace), w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &corev1.ConfigMapList{ TypeMeta: metav1.TypeMeta{Kind: "ConfigMapList", APIVersion: "v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } -func (s *ClusterState) listConfigMapsAllNamespaces(w http.ResponseWriter) { +func (s *ClusterState) listConfigMapsAllNamespaces(w http.ResponseWriter, r *http.Request) { s.mu.RLock() defer s.mu.RUnlock() - items := s.collectConfigMapsLocked("") + items, cont, ok := listPage(s.collectConfigMapsLocked(""), w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &corev1.ConfigMapList{ TypeMeta: metav1.TypeMeta{Kind: "ConfigMapList", APIVersion: "v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } @@ -227,6 +242,12 @@ func (s *ClusterState) updateConfigMap(w http.ResponseWriter, r *http.Request, n in.ResourceVersion = bumpResourceVersion(cur.ResourceVersion) in.TypeMeta = cur.TypeMeta + if isDryRun(r) { + writeJSON(w, http.StatusOK, &in) + + return + } + cm := in s.configMaps[key] = &cm s.wConfigMaps.publish(EventModified, namespace, *cm.DeepCopy()) @@ -256,12 +277,19 @@ func (s *ClusterState) patchConfigMap(w http.ResponseWriter, r *http.Request, na } patched.ResourceVersion = bumpResourceVersion(cur.ResourceVersion) + + if isDryRun(r) { + writeJSON(w, http.StatusOK, patched) + + return + } + s.configMaps[key] = patched s.wConfigMaps.publish(EventModified, namespace, *patched.DeepCopy()) writeJSON(w, http.StatusOK, patched) } -func (s *ClusterState) deleteConfigMap(w http.ResponseWriter, namespace, name string) { +func (s *ClusterState) deleteConfigMap(w http.ResponseWriter, r *http.Request, namespace, name string) { key := configMapKey(namespace, name) s.mu.Lock() @@ -274,6 +302,12 @@ func (s *ClusterState) deleteConfigMap(w http.ResponseWriter, namespace, name st return } + if isDryRun(r) { + writeJSON(w, http.StatusOK, cm.DeepCopy()) + + return + } + delete(s.configMaps, key) s.wConfigMaps.publish(EventDeleted, namespace, *cm.DeepCopy()) writeJSON(w, http.StatusOK, cm.DeepCopy()) @@ -293,9 +327,9 @@ func configMapKey(namespace, name string) string { } // stamp fills in the implicit fields a real apiserver writes on Create: -// UID, creationTimestamp, resourceVersion. -func stamp(om *metav1.ObjectMeta) { +// UID, creationTimestamp (from the cluster clock), resourceVersion. +func (s *ClusterState) stamp(om *metav1.ObjectMeta) { om.UID = types.UID(newUID()) - om.CreationTimestamp = metav1.NewTime(time.Now()) + om.CreationTimestamp = s.now() om.ResourceVersion = "1" } diff --git a/services/kubernetes/crd.go b/services/kubernetes/crd.go new file mode 100644 index 00000000..997753a4 --- /dev/null +++ b/services/kubernetes/crd.go @@ -0,0 +1,128 @@ +package kubernetes + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// CustomResourceDefinition support. A CRD is pure API surface with no +// container/runtime semantics, so it fits the registry model almost exactly: +// creating a CRD materializes a registry store for its custom-resource GVR, and +// from then on the generic handler serves that kind's CRUD/list/watch/status, +// while discovery (derived from the live registry) advertises it. Deleting the +// CRD deregisters the store and cascade-deletes its custom resources. +// +// Structural schema VALIDATION of custom resources is a documented +// simplification: CRs are accepted and stored as-is (matching how the emulator +// already treats server-side apply as a merge). + +// crdRegistryDefs registers the apiextensions.k8s.io/v1 CustomResourceDefinition +// kind itself. Its reconcile hook materializes CR stores; its onDelete hook +// tears them down. +func crdRegistryDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupExtensions, version: "v1", + kind: "CustomResourceDefinition", listKind: "CustomResourceDefinitionList", + plural: "customresourcedefinitions", namespaced: false, hasStatus: true, + reconcile: reconcileCRD, onDelete: onDeleteCRD, + }, + } +} + +// reconcileCRD materializes a registry store for every served version of the +// CRD, then marks the CRD Established so kubectl/operators treat it as ready. +func reconcileCRD(s *ClusterState, obj *unstructured.Unstructured) { + for _, d := range crdResourceDefs(obj) { + s.reg.addStore(d) + } + + setCRDEstablished(obj) +} + +// onDeleteCRD deregisters the CRD's CR stores and cascade-deletes any custom +// resources that were created against them. +func onDeleteCRD(s *ClusterState, obj *unstructured.Unstructured) { + for _, d := range crdResourceDefs(obj) { + if st := s.reg.getStore(d.group, d.version, d.plural); st != nil { + for key, cr := range st.items { + delete(st.items, key) + st.watch.publish(EventDeleted, cr.GetNamespace(), *cr.DeepCopy()) + } + } + + s.reg.removeStore(d.group, d.version, d.plural) + } +} + +// crdResourceDefs derives the registry resourceDef(s) — one per served version — +// from a CRD object. Returns nil for a structurally-incomplete CRD (missing +// group/plural/kind) rather than registering a shadowing or malformed store. +func crdResourceDefs(obj *unstructured.Unstructured) []*resourceDef { + group, _, _ := unstructured.NestedString(obj.Object, "spec", "group") + plural, _, _ := unstructured.NestedString(obj.Object, "spec", "names", "plural") + kind, _, _ := unstructured.NestedString(obj.Object, "spec", "names", "kind") + + if group == "" || plural == "" || kind == "" { + return nil + } + + listKind, _, _ := unstructured.NestedString(obj.Object, "spec", "names", "listKind") + if listKind == "" { + listKind = kind + "List" + } + + scope, _, _ := unstructured.NestedString(obj.Object, "spec", "scope") + namespaced := scope != "Cluster" + + versions, _, _ := unstructured.NestedSlice(obj.Object, "spec", "versions") + + out := make([]*resourceDef, 0, len(versions)) + + for _, v := range versions { + vm, ok := v.(map[string]any) + if !ok { + continue + } + + if served, _, _ := unstructured.NestedBool(vm, "served"); !served { + continue + } + + name, _, _ := unstructured.NestedString(vm, "name") + if name == "" { + continue + } + + _, hasStatus, _ := unstructured.NestedMap(vm, "subresources", "status") + _, hasScale, _ := unstructured.NestedMap(vm, "subresources", "scale") + + out = append(out, &resourceDef{ + group: group, version: name, kind: kind, listKind: listKind, + plural: plural, namespaced: namespaced, hasStatus: hasStatus, hasScale: hasScale, + }) + } + + return out +} + +// setCRDEstablished fills the CRD's status the way the apiextensions controller +// would: acceptedNames mirrors spec.names, storedVersions lists the served +// versions, and the NamesAccepted/Established conditions go True. +func setCRDEstablished(obj *unstructured.Unstructured) { + if names, found, _ := unstructured.NestedMap(obj.Object, "spec", "names"); found { + _ = unstructured.SetNestedMap(obj.Object, names, "status", "acceptedNames") + } + + defs := crdResourceDefs(obj) + stored := make([]any, 0, len(defs)) + + for _, d := range defs { + stored = append(stored, d.version) + } + + _ = unstructured.SetNestedSlice(obj.Object, stored, "status", "storedVersions") + _ = unstructured.SetNestedSlice(obj.Object, []any{ + map[string]any{"type": "NamesAccepted", "status": "True", "reason": "NoConflicts"}, + map[string]any{"type": "Established", "status": "True", "reason": "InitialNamesAccepted"}, + }, "status", "conditions") +} diff --git a/services/kubernetes/cron_schedule.go b/services/kubernetes/cron_schedule.go new file mode 100644 index 00000000..f8bbf47f --- /dev/null +++ b/services/kubernetes/cron_schedule.go @@ -0,0 +1,218 @@ +package kubernetes + +import ( + "strconv" + "strings" + "time" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// Self-contained deterministic parser for the standard 5-field cron syntax +// (minute hour day-of-month month day-of-week). It supports `*`, `*/n` steps, +// comma lists, `a-b` ranges, and `a-b/n` / `a/n` stepped ranges. It intentionally +// does NOT support the nonstandard extensions (`@hourly`-style macros, `L`, `W`, +// `#`, `?`, or a seconds/year field) — callers get an error for those so an +// unschedulable expression fails loudly rather than silently never firing. + +const ( + cronFieldCount = 5 + + minuteLo, minuteHi = 0, 59 + hourLo, hourHi = 0, 23 + domLo, domHi = 1, 31 + monthLo, monthHi = 1, 12 + dowLo, dowHi = 0, 6 // 0 = Sunday, matching time.Weekday() + + // nextSearchDays bounds the forward scan in nextAfter so an expression that + // can never match (e.g. Feb 30) returns an error instead of looping forever. + // A window over 4 years covers leap-year day-of-week alignment. + nextSearchDays = 366*4 + 1 +) + +// cronSchedule is a parsed cron expression: one allowed-value set per field. +type cronSchedule struct { + minute map[int]bool + hour map[int]bool + dom map[int]bool + month map[int]bool + dow map[int]bool + + // Cron day matching: when both day-of-month and day-of-week are restricted + // (neither is `*`), a day matches if EITHER field matches; when only one is + // restricted, that field alone gates the day. + domRestricted bool + dowRestricted bool +} + +// parseSchedule parses a standard 5-field cron expression. +func parseSchedule(spec string) (*cronSchedule, error) { + fields := strings.Fields(spec) + if len(fields) != cronFieldCount { + return nil, cerrors.Newf(cerrors.InvalidArgument, + "cron: expected %d fields, got %d in %q", cronFieldCount, len(fields), spec) + } + + sched := &cronSchedule{ + domRestricted: fields[2] != "*", + dowRestricted: fields[4] != "*", + } + + specs := []struct { + field string + lo, hi int + dst *map[int]bool + fieldPos string + }{ + {fields[0], minuteLo, minuteHi, &sched.minute, "minute"}, + {fields[1], hourLo, hourHi, &sched.hour, "hour"}, + {fields[2], domLo, domHi, &sched.dom, "day-of-month"}, + {fields[3], monthLo, monthHi, &sched.month, "month"}, + {fields[4], dowLo, dowHi, &sched.dow, "day-of-week"}, + } + + for _, sp := range specs { + set, err := parseCronField(sp.field, sp.lo, sp.hi) + if err != nil { + return nil, cerrors.Newf(cerrors.InvalidArgument, "cron %s: %v", sp.fieldPos, err) + } + + *sp.dst = set + } + + return sched, nil +} + +// parseCronField parses one comma-separated cron field into its value set. +func parseCronField(field string, lo, hi int) (map[int]bool, error) { + out := make(map[int]bool) + + for _, part := range strings.Split(field, ",") { + if err := parseCronPart(part, lo, hi, out); err != nil { + return nil, err + } + } + + return out, nil +} + +// parseCronPart parses a single list element (`*`, `n`, `a-b`, or any of those +// with a `/step`) and adds its values to out. +func parseCronPart(part string, lo, hi int, out map[int]bool) error { + rangeStr := part + step := 1 + stepped := false + + if slash := strings.IndexByte(part, '/'); slash >= 0 { + s, err := strconv.Atoi(part[slash+1:]) + if err != nil || s <= 0 { + return cerrors.Newf(cerrors.InvalidArgument, "invalid step %q", part) + } + + rangeStr, step, stepped = part[:slash], s, true + } + + start, end, err := parseCronRange(rangeStr, lo, hi, stepped) + if err != nil { + return err + } + + for v := start; v <= end; v += step { + out[v] = true + } + + return nil +} + +// parseCronRange resolves the range a step applies over. A bare number with a +// step (`a/n`) runs from a to the field maximum, matching standard cron. +func parseCronRange(rangeStr string, lo, hi int, stepped bool) (start, end int, err error) { + if rangeStr == "*" { + return lo, hi, nil + } + + if dash := strings.IndexByte(rangeStr, '-'); dash >= 0 { + start, err = parseBounded(rangeStr[:dash], lo, hi) + if err != nil { + return 0, 0, err + } + + end, err = parseBounded(rangeStr[dash+1:], lo, hi) + if err != nil { + return 0, 0, err + } + + if start > end { + return 0, 0, cerrors.Newf(cerrors.InvalidArgument, "range %q is inverted", rangeStr) + } + + return start, end, nil + } + + v, err := parseBounded(rangeStr, lo, hi) + if err != nil { + return 0, 0, err + } + + if stepped { + return v, hi, nil + } + + return v, v, nil +} + +// parseBounded parses a single integer and checks it lies within [lo, hi]. +func parseBounded(s string, lo, hi int) (int, error) { + v, err := strconv.Atoi(s) + if err != nil { + return 0, cerrors.Newf(cerrors.InvalidArgument, "invalid value %q", s) + } + + if v < lo || v > hi { + return 0, cerrors.Newf(cerrors.InvalidArgument, "value %d out of range [%d,%d]", v, lo, hi) + } + + return v, nil +} + +// matches reports whether t (at minute granularity) satisfies the schedule. +func (c *cronSchedule) matches(t time.Time) bool { + if !c.minute[t.Minute()] || !c.hour[t.Hour()] || !c.month[int(t.Month())] { + return false + } + + return c.dayMatches(t) +} + +// dayMatches applies cron's day-of-month / day-of-week OR semantics. +func (c *cronSchedule) dayMatches(t time.Time) bool { + domOK := c.dom[t.Day()] + dowOK := c.dow[int(t.Weekday())] + + switch { + case c.domRestricted && c.dowRestricted: + return domOK || dowOK + case c.domRestricted: + return domOK + case c.dowRestricted: + return dowOK + default: + return true + } +} + +// nextAfter returns the earliest scheduled time strictly after t (minute +// resolution, in t's location). It errors if no match falls inside the bounded +// search window, so an impossible schedule can't loop forever. +func (c *cronSchedule) nextAfter(t time.Time) (time.Time, error) { + cursor := t.Truncate(time.Minute).Add(time.Minute) + limit := cursor.AddDate(0, 0, nextSearchDays) + + for ; cursor.Before(limit); cursor = cursor.Add(time.Minute) { + if c.matches(cursor) { + return cursor, nil + } + } + + return time.Time{}, cerrors.Newf(cerrors.InvalidArgument, "cron: no scheduled time within %d days", nextSearchDays) +} diff --git a/services/kubernetes/cronjob.go b/services/kubernetes/cronjob.go new file mode 100644 index 00000000..9bcbdf03 --- /dev/null +++ b/services/kubernetes/cronjob.go @@ -0,0 +1,235 @@ +package kubernetes + +import ( + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" +) + +// CronJob scheduling. cloudemu runs synchronously with no background timer, so +// callers drive the scheduler by calling TickCronJobs (reachable via +// APIServer.Lookup(uid)) — advancing the injected clock between ticks. Each tick +// evaluates every CronJob's cron `spec.schedule` against the clock and only +// materializes a Job when a scheduled time falls in (lastScheduleTime, now], +// honoring concurrencyPolicy and startingDeadlineSeconds. This makes scheduling +// real due-evaluation rather than "fire every CronJob on every tick". + +// Concurrency policies (batch/v1 CronJobSpec.concurrencyPolicy). Allow is the +// default when the field is empty. +const ( + concurrencyForbid = "Forbid" + concurrencyReplace = "Replace" + + // maxCatchupIterations bounds the scan for the most-recent due slot when many + // scheduled times were missed between ticks (a large clock jump), so a tiny + // interval over a huge gap can't spin unbounded. + maxCatchupIterations = 1000 +) + +// TickCronJobs evaluates every non-suspended CronJob against the cluster clock, +// creating a Job for any schedule that is due since its last run. Safe for +// external callers. +func (s *ClusterState) TickCronJobs() { + s.mu.Lock() + defer s.mu.Unlock() + + cronStore := s.reg.getStore(apiGroupBatch, "v1", "cronjobs") + jobStore := s.reg.getStore(apiGroupBatch, "v1", "jobs") + + if cronStore == nil || jobStore == nil { + return + } + + now := s.now().Time + for _, cj := range cronStore.items { + s.evaluateCronJobLocked(cj, jobStore, now) + } +} + +// evaluateCronJobLocked runs the due-check + concurrency gates for one CronJob +// and fires it at most once. Callers hold s.mu. +func (s *ClusterState) evaluateCronJobLocked(cj *unstructured.Unstructured, jobStore *registryStore, now time.Time) { + if suspended, _, _ := unstructured.NestedBool(cj.Object, "spec", "suspend"); suspended { + return + } + + scheduleStr, _, _ := unstructured.NestedString(cj.Object, "spec", "schedule") + + sched, err := parseSchedule(scheduleStr) + if err != nil { + return + } + + fireTime, due := dueSchedule(sched, lastScheduleOrCreation(cj), now) + if !due { + return + } + + // A run whose scheduled time is already older than startingDeadlineSeconds is + // missed: record it so it isn't re-evaluated, but don't run it. + if missedStartingDeadline(cj, fireTime, now) { + setLastScheduleTime(cj, fireTime) + + return + } + + // Forbid with a still-active prior Job skips this run WITHOUT advancing + // lastScheduleTime, so the slot fires once the prior Job finishes. + if !s.applyConcurrencyLocked(cj, jobStore) { + return + } + + s.fireCronJobLocked(cj, jobStore, fireTime) +} + +// dueSchedule reports the most recent scheduled time in (last, now], if any. The +// most-recent (not earliest) slot is chosen so a large clock jump collapses a +// backlog into a single run, matching the upstream controller. +func dueSchedule(sched *cronSchedule, last, now time.Time) (time.Time, bool) { + fire, err := sched.nextAfter(last) + if err != nil || fire.After(now) { + return time.Time{}, false + } + + for range maxCatchupIterations { + following, ferr := sched.nextAfter(fire) + if ferr != nil || following.After(now) { + break + } + + fire = following + } + + return fire, true +} + +// lastScheduleOrCreation is the reference time the next due slot is measured +// from: status.lastScheduleTime once set, else the CronJob's creation time. +func lastScheduleOrCreation(cj *unstructured.Unstructured) time.Time { + if str, found, _ := unstructured.NestedString(cj.Object, "status", "lastScheduleTime"); found && str != "" { + if t, err := time.Parse(time.RFC3339, str); err == nil { + return t + } + } + + return cj.GetCreationTimestamp().Time +} + +// setLastScheduleTime records the fired (or missed) slot on status so the next +// tick at the same wall-clock time doesn't re-create the Job. +func setLastScheduleTime(cj *unstructured.Unstructured, t time.Time) { + _ = unstructured.SetNestedField(cj.Object, t.UTC().Format(time.RFC3339), "status", "lastScheduleTime") +} + +// missedStartingDeadline reports whether the due slot is older than the +// CronJob's startingDeadlineSeconds relative to now (unset = no deadline). +func missedStartingDeadline(cj *unstructured.Unstructured, fireTime, now time.Time) bool { + secs, found, _ := unstructured.NestedInt64(cj.Object, "spec", "startingDeadlineSeconds") + if !found { + return false + } + + return now.Sub(fireTime) > time.Duration(secs)*time.Second +} + +// applyConcurrencyLocked enforces concurrencyPolicy against the CronJob's active +// Jobs and reports whether this run may proceed. Forbid blocks while a prior Job +// is active; Replace deletes active Jobs first; Allow (default) always proceeds. +// Callers hold s.mu. +func (s *ClusterState) applyConcurrencyLocked(cj *unstructured.Unstructured, jobStore *registryStore) bool { + active := activeJobsFor(cj, jobStore) + if len(active) == 0 { + return true + } + + policy, _, _ := unstructured.NestedString(cj.Object, "spec", "concurrencyPolicy") + + switch policy { + case concurrencyForbid: + return false + case concurrencyReplace: + for _, job := range active { + s.deleteJobLocked(job, jobStore) + } + + return true + default: + return true + } +} + +// activeJobsFor returns the CronJob's owned Jobs that are not yet terminal +// (no Complete/Failed condition). Callers hold s.mu. +func activeJobsFor(cj *unstructured.Unstructured, jobStore *registryStore) []*unstructured.Unstructured { + uid := cj.GetUID() + + var active []*unstructured.Unstructured + + for _, job := range jobStore.items { + if ownedBy(job.GetOwnerReferences(), uid) && !jobTerminal(job) { + active = append(active, job) + } + } + + return active +} + +// jobTerminal reports whether a Job has finished (Complete or Failed True). +func jobTerminal(job *unstructured.Unstructured) bool { + conds, _, _ := unstructured.NestedSlice(job.Object, "status", "conditions") + for _, raw := range conds { + cond, ok := raw.(map[string]any) + if !ok { + continue + } + + ctype, _, _ := unstructured.NestedString(cond, "type") + cstatus, _, _ := unstructured.NestedString(cond, "status") + + if cstatus == "True" && (ctype == "Complete" || ctype == "Failed") { + return true + } + } + + return false +} + +// deleteJobLocked removes a Job and cascade-collects its Pods (Replace policy). +// Callers hold s.mu. +func (s *ClusterState) deleteJobLocked(job *unstructured.Unstructured, jobStore *registryStore) { + delete(jobStore.items, objKey(job.GetNamespace(), job.GetName())) + jobStore.bumpRVLocked() + s.garbageCollectLocked(job.GetUID()) + jobStore.watch.publish(EventDeleted, job.GetNamespace(), *job.DeepCopy()) +} + +// fireCronJobLocked materializes one Job from the CronJob's jobTemplate, runs the +// Job reconciler, and records the fired schedule time. Callers hold s.mu. +func (s *ClusterState) fireCronJobLocked(cj *unstructured.Unstructured, jobStore *registryStore, fireTime time.Time) { + jobSpec, found, _ := unstructured.NestedMap(cj.Object, "spec", "jobTemplate", "spec") + if !found { + return + } + + ns := cj.GetNamespace() + jobName := cj.GetName() + "-" + shortID() + + job := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "batch/v1", + "kind": "Job", + "metadata": map[string]any{"name": jobName, "namespace": ns}, + "spec": jobSpec, + }} + job.SetUID(types.UID(newUID())) + job.SetCreationTimestamp(s.now()) + job.SetOwnerReferences([]metav1.OwnerReference{ownerRefOf(cj)}) + + jobStore.stampRVLocked(job) + jobStore.items[objKey(ns, jobName)] = job + reconcileJob(s, job) + jobStore.watch.publish(EventAdded, ns, *job.DeepCopy()) + + setLastScheduleTime(cj, fireTime) +} diff --git a/services/kubernetes/cronjob_internal_test.go b/services/kubernetes/cronjob_internal_test.go new file mode 100644 index 00000000..0fe3855c --- /dev/null +++ b/services/kubernetes/cronjob_internal_test.go @@ -0,0 +1,93 @@ +// Internal test for Phase 3 CronJob scheduling: TickCronJobs materializes a Job +// from the CronJob's jobTemplate. + +package kubernetes + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/config" +) + +func TestTickCronJobs_MaterializesJob(t *testing.T) { + api := NewAPIServer() + // Deterministic clock so the schedule's due-evaluation is reproducible: the + // CronJob is created at an off-boundary time, then the clock is advanced to a + // 5-minute boundary where "*/5 * * * *" is actually due. + clock := config.NewFakeClock(time.Date(2026, time.January, 1, 0, 0, 30, 0, time.UTC)) + api.SetClock(clock) + uid, state := api.RegisterCluster() + ts := httptest.NewServer(api) + + defer ts.Close() + + // Create a CronJob via the HTTP path. + cj := map[string]any{ + "apiVersion": "batch/v1", "kind": "CronJob", + "metadata": map[string]any{"name": "backup"}, + "spec": map[string]any{ + "schedule": "*/5 * * * *", + "jobTemplate": map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ + "containers": []any{map[string]any{"name": "c", "image": "busybox"}}, + }, + }, + }, + }, + }, + } + + body, _ := json.Marshal(cj) + req, _ := http.NewRequest(http.MethodPost, + ts.URL+"/k8s/"+uid+"/apis/batch/v1/namespaces/default/cronjobs", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("create cronjob: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create cronjob: status %d", resp.StatusCode) + } + + // No Jobs yet (no scheduler has fired). + if got := countJobs(state); got != 0 { + t.Fatalf("before tick: %d jobs, want 0", got) + } + + // Not due yet at creation time; only a boundary crossing fires the schedule. + state.TickCronJobs() + + if got := countJobs(state); got != 0 { + t.Fatalf("before boundary: %d jobs, want 0", got) + } + + // Advance to the 00:05:00 boundary and fire the schedule once. + clock.Advance(4*time.Minute + 30*time.Second) + state.TickCronJobs() + + if got := countJobs(state); got != 1 { + t.Fatalf("after tick: %d jobs, want 1", got) + } +} + +func countJobs(state *ClusterState) int { + state.mu.RLock() + defer state.mu.RUnlock() + + st := state.reg.getStore(apiGroupBatch, "v1", "jobs") + if st == nil { + return 0 + } + + return len(st.items) +} diff --git a/services/kubernetes/cronjob_test.go b/services/kubernetes/cronjob_test.go new file mode 100644 index 00000000..9f57de1c --- /dev/null +++ b/services/kubernetes/cronjob_test.go @@ -0,0 +1,312 @@ +package kubernetes + +import ( + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + + "github.com/stackshy/cloudemu/v2/config" +) + +// cronBase is an off-boundary reference time (00:00:30) so a freshly created +// CronJob is never immediately "due" at its own creation timestamp. +func cronBase() time.Time { + return time.Date(2026, time.January, 1, 0, 0, 30, 0, time.UTC) +} + +// newCronFixture returns a cluster whose data-plane clock is the returned +// FakeClock, so tests can advance time deterministically between ticks. +func newCronFixture(t *testing.T) (*ClusterState, *config.FakeClock) { + t.Helper() + + api := NewAPIServer() + clock := config.NewFakeClock(cronBase()) + api.SetClock(clock) + + _, state := api.RegisterCluster() + + return state, clock +} + +// putCronJob inserts a CronJob straight into the store (creationTimestamp from +// the fake clock), returning it so tests can reference its UID. +func putCronJob(t *testing.T, s *ClusterState, name, schedule, policy string, deadlineSecs int64) *unstructured.Unstructured { + t.Helper() + + s.mu.Lock() + defer s.mu.Unlock() + + store := s.reg.getStore(apiGroupBatch, "v1", "cronjobs") + + cj := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "batch/v1", + "kind": "CronJob", + "metadata": map[string]any{"name": name, "namespace": "default"}, + "spec": map[string]any{ + "schedule": schedule, + "jobTemplate": map[string]any{"spec": map[string]any{ + "template": map[string]any{"spec": map[string]any{ + "containers": []any{map[string]any{"name": "c", "image": "busybox"}}, + }}, + }}, + }, + }} + + if policy != "" { + _ = unstructured.SetNestedField(cj.Object, policy, "spec", "concurrencyPolicy") + } + + if deadlineSecs > 0 { + _ = unstructured.SetNestedField(cj.Object, deadlineSecs, "spec", "startingDeadlineSeconds") + } + + cj.SetUID(types.UID(newUID())) + cj.SetCreationTimestamp(s.now()) + store.stampRVLocked(cj) + store.items[objKey("default", name)] = cj + + return cj +} + +// putActiveJob injects a non-terminal Job owned by cj (no Complete/Failed +// condition), simulating a prior run still in flight. +func putActiveJob(t *testing.T, s *ClusterState, cj *unstructured.Unstructured, name string) { + t.Helper() + + s.mu.Lock() + defer s.mu.Unlock() + + store := s.reg.getStore(apiGroupBatch, "v1", "jobs") + + job := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "batch/v1", + "kind": "Job", + "metadata": map[string]any{"name": name, "namespace": "default"}, + "status": map[string]any{"active": int64(1)}, + }} + job.SetUID(types.UID(newUID())) + job.SetOwnerReferences([]metav1.OwnerReference{ownerRefOf(cj)}) + store.stampRVLocked(job) + store.items[objKey("default", name)] = job +} + +func TestTickCronJobs_FiresOnceAtBoundary(t *testing.T) { + state, clock := newCronFixture(t) + putCronJob(t, state, "backup", "*/5 * * * *", "", 0) + + // Not due yet: only 30s past creation, next slot is 00:05:00. + state.TickCronJobs() + + if got := countJobs(state); got != 0 { + t.Fatalf("before boundary: %d jobs, want 0", got) + } + + // Advance to the 00:05:00 boundary and fire. + clock.Advance(4*time.Minute + 30*time.Second) + state.TickCronJobs() + + if got := countJobs(state); got != 1 { + t.Fatalf("at boundary: %d jobs, want 1", got) + } + + // Ticking again at the SAME wall-clock time must not double-create. + state.TickCronJobs() + + if got := countJobs(state); got != 1 { + t.Fatalf("re-tick same time: %d jobs, want 1 (double-create regression)", got) + } +} + +func TestTickCronJobs_NotDue(t *testing.T) { + state, _ := newCronFixture(t) + putCronJob(t, state, "nightly", "0 0 * * *", "", 0) + + // Midnight-only schedule; the clock sits at 00:00:30, so the next due slot is + // tomorrow — nothing should fire. + state.TickCronJobs() + + if got := countJobs(state); got != 0 { + t.Fatalf("not-due schedule: %d jobs, want 0", got) + } +} + +func TestTickCronJobs_ForbidSkipsWhilePriorActive(t *testing.T) { + state, clock := newCronFixture(t) + cj := putCronJob(t, state, "report", "*/5 * * * *", concurrencyForbid, 0) + putActiveJob(t, state, cj, "report-prior") + + clock.Advance(4*time.Minute + 30*time.Second) // reach 00:05:00 + state.TickCronJobs() + + // The prior Job is still active, so Forbid blocks a second Job. + if got := countJobs(state); got != 1 { + t.Fatalf("forbid with active job: %d jobs, want 1", got) + } +} + +func TestTickCronJobs_ReplaceDeletesActive(t *testing.T) { + state, clock := newCronFixture(t) + cj := putCronJob(t, state, "sync", "*/5 * * * *", concurrencyReplace, 0) + putActiveJob(t, state, cj, "sync-prior") + + clock.Advance(4*time.Minute + 30*time.Second) + state.TickCronJobs() + + // Replace deletes the active prior Job and creates the new one: net one Job. + if got := countJobs(state); got != 1 { + t.Fatalf("replace: %d jobs, want 1", got) + } + + if jobExists(state, "sync-prior") { + t.Fatalf("replace: prior job should have been deleted") + } +} + +func TestTickCronJobs_StartingDeadlineSkipsStaleRun(t *testing.T) { + state, clock := newCronFixture(t) + putCronJob(t, state, "ingest", "*/5 * * * *", "", 60) // 60s deadline + + // Jump to 00:22:00 — the most recent slot (00:20:00) is 120s stale, past the + // 60s deadline, so the missed run is skipped. + clock.Advance(21*time.Minute + 30*time.Second) + state.TickCronJobs() + + if got := countJobs(state); got != 0 { + t.Fatalf("stale run past deadline: %d jobs, want 0", got) + } + + // A subsequent on-time boundary still fires (deadline only skips stale slots). + clock.Advance(3 * time.Minute) // 00:25:00 + state.TickCronJobs() + + if got := countJobs(state); got != 1 { + t.Fatalf("on-time run within deadline: %d jobs, want 1", got) + } +} + +func TestTickCronJobs_SuspendedNeverFires(t *testing.T) { + state, clock := newCronFixture(t) + cj := putCronJob(t, state, "paused", "*/5 * * * *", "", 0) + + state.mu.Lock() + _ = unstructured.SetNestedField(cj.Object, true, "spec", "suspend") + state.mu.Unlock() + + clock.Advance(10 * time.Minute) + state.TickCronJobs() + + if got := countJobs(state); got != 0 { + t.Fatalf("suspended cronjob: %d jobs, want 0", got) + } +} + +func jobExists(state *ClusterState, name string) bool { + state.mu.RLock() + defer state.mu.RUnlock() + + st := state.reg.getStore(apiGroupBatch, "v1", "jobs") + _, ok := st.items[objKey("default", name)] + + return ok +} + +// --- cron parser unit tests ------------------------------------------------- + +func TestParseSchedule_Errors(t *testing.T) { + for _, spec := range []string{"", "* * * *", "* * * * * *", "bad * * * *", "*/0 * * * *", "60 * * * *", "9-5 * * * *"} { + if _, err := parseSchedule(spec); err == nil { + t.Errorf("parseSchedule(%q): want error, got nil", spec) + } + } +} + +func TestParseCronField_Forms(t *testing.T) { + tests := []struct { + name string + field string + lo, hi int + wantMember []int + wantAbsent []int + }{ + {"star", "*", 0, 5, []int{0, 3, 5}, nil}, + {"step", "*/15", 0, 59, []int{0, 15, 30, 45}, []int{1, 14, 46}}, + {"list", "0,30", 0, 59, []int{0, 30}, []int{15, 45}}, + {"range", "9-17", 0, 23, []int{9, 13, 17}, []int{8, 18}}, + {"steppedRange", "10-20/5", 0, 59, []int{10, 15, 20}, []int{11, 25}}, + {"openStep", "50/5", 0, 59, []int{50, 55}, []int{45, 49}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + set, err := parseCronField(tc.field, tc.lo, tc.hi) + if err != nil { + t.Fatalf("parseCronField(%q): %v", tc.field, err) + } + + for _, v := range tc.wantMember { + if !set[v] { + t.Errorf("%q: expected %d to be a member", tc.field, v) + } + } + + for _, v := range tc.wantAbsent { + if set[v] { + t.Errorf("%q: expected %d to be absent", tc.field, v) + } + } + }) + } +} + +func TestNextAfter_Boundary(t *testing.T) { + sched, err := parseSchedule("*/5 * * * *") + if err != nil { + t.Fatalf("parse: %v", err) + } + + // From 00:00:30 the next slot is 00:05:00. + got, err := sched.nextAfter(cronBase()) + if err != nil { + t.Fatalf("nextAfter: %v", err) + } + + want := time.Date(2026, time.January, 1, 0, 5, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("nextAfter(%v) = %v, want %v", cronBase(), got, want) + } + + // From exactly 00:05:00 the next slot is strictly after: 00:10:00. + got2, _ := sched.nextAfter(want) + + want2 := time.Date(2026, time.January, 1, 0, 10, 0, 0, time.UTC) + if !got2.Equal(want2) { + t.Fatalf("nextAfter(%v) = %v, want %v", want, got2, want2) + } +} + +func TestDayMatches_DomOrDowSemantics(t *testing.T) { + // Both day fields restricted: a day matches if EITHER matches (cron OR rule). + // 2026-01-01 is a Thursday (weekday 4), day-of-month 1. + sched, err := parseSchedule("0 0 1 * 0") // day-of-month 1 OR Sunday + if err != nil { + t.Fatalf("parse: %v", err) + } + + thu1 := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) // dom=1 → match + if !sched.matches(thu1) { + t.Errorf("expected match on day-of-month 1") + } + + sun4 := time.Date(2026, time.January, 4, 0, 0, 0, 0, time.UTC) // Sunday → match + if !sched.matches(sun4) { + t.Errorf("expected match on Sunday") + } + + fri2 := time.Date(2026, time.January, 2, 0, 0, 0, 0, time.UTC) // neither → no match + if sched.matches(fri2) { + t.Errorf("expected no match on a non-1, non-Sunday day") + } +} diff --git a/services/kubernetes/deployment.go b/services/kubernetes/deployment.go index 3eb23ba6..d7c12ca4 100644 --- a/services/kubernetes/deployment.go +++ b/services/kubernetes/deployment.go @@ -18,6 +18,10 @@ const apiGroupApps = "apps" // resourceDeployments is the plural resource segment for Deployments. const resourceDeployments = "deployments" +// resourcePods is the pods resource path segment, shared by the typed dispatch +// and the pod subresource (log/exec) router. +const resourcePods = "pods" + // serveDeployments dispatches /apis/apps/v1/{namespaces/{ns}/deployments| // deployments} requests. Deployments are the first apps/v1 resource so the // route group check is different from the core/v1 handlers. @@ -41,7 +45,7 @@ func (s *ClusterState) serveDeployments(w http.ResponseWriter, r *http.Request, return } - s.listDeploymentsAllNamespaces(w) + s.listDeploymentsAllNamespaces(w, r) return } @@ -70,7 +74,7 @@ func (s *ClusterState) serveDeploymentCollection(w http.ResponseWriter, r *http. return } - s.listDeployments(w, namespace) + s.listDeployments(w, r, namespace) case http.MethodPost: s.createDeployment(w, r, namespace) default: @@ -96,7 +100,7 @@ func (s *ClusterState) serveDeploymentItem(w http.ResponseWriter, r *http.Reques case http.MethodPatch: s.patchDeployment(w, r, namespace, name) case http.MethodDelete: - s.deleteDeployment(w, namespace, name) + s.deleteDeployment(w, r, namespace, name) default: writeMethodNotAllowed(w, "k8s api: deployment item: method not allowed: "+r.Method) } @@ -127,10 +131,20 @@ func (s *ClusterState) createDeployment(w http.ResponseWriter, r *http.Request, return } - stamp(&in.ObjectMeta) + s.stamp(&in.ObjectMeta) in.TypeMeta = metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"} in.Generation = 1 + if handled := s.admit(w, opCreate, gvrDeployments(), &in); handled { + return + } + + if isDryRun(r) { + writeJSON(w, http.StatusCreated, &in) + + return + } + dep := in s.deployments[key] = &dep // Reconcile: materialize Running Pods and populate status + Service @@ -140,24 +154,34 @@ func (s *ClusterState) createDeployment(w http.ResponseWriter, r *http.Request, writeJSON(w, http.StatusCreated, &dep) } -func (s *ClusterState) listDeployments(w http.ResponseWriter, namespace string) { +func (s *ClusterState) listDeployments(w http.ResponseWriter, r *http.Request, namespace string) { s.mu.RLock() defer s.mu.RUnlock() - items := s.collectDeploymentsLocked(namespace) + items, cont, ok := listPage(s.collectDeploymentsLocked(namespace), w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &appsv1.DeploymentList{ TypeMeta: metav1.TypeMeta{Kind: "DeploymentList", APIVersion: "apps/v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } -func (s *ClusterState) listDeploymentsAllNamespaces(w http.ResponseWriter) { +func (s *ClusterState) listDeploymentsAllNamespaces(w http.ResponseWriter, r *http.Request) { s.mu.RLock() defer s.mu.RUnlock() - items := s.collectDeploymentsLocked("") + items, cont, ok := listPage(s.collectDeploymentsLocked(""), w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &appsv1.DeploymentList{ TypeMeta: metav1.TypeMeta{Kind: "DeploymentList", APIVersion: "apps/v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } @@ -226,6 +250,12 @@ func (s *ClusterState) updateDeployment(w http.ResponseWriter, r *http.Request, in.TypeMeta = cur.TypeMeta in.Generation = generationFor(cur.Generation, &in.Spec, &cur.Spec) + if isDryRun(r) { + writeJSON(w, http.StatusOK, &in) + + return + } + dep := in s.deployments[key] = &dep s.reconcileDeploymentLocked(&dep) @@ -254,6 +284,12 @@ func (s *ClusterState) patchDeployment(w http.ResponseWriter, r *http.Request, n patched.ResourceVersion = bumpResourceVersion(cur.ResourceVersion) patched.Generation = generationFor(cur.Generation, &patched.Spec, &cur.Spec) + if isDryRun(r) { + writeJSON(w, http.StatusOK, patched) + + return + } + s.deployments[key] = patched s.reconcileDeploymentLocked(patched) s.wDeployments.publish(EventModified, namespace, *patched.DeepCopy()) @@ -271,7 +307,7 @@ func generationFor(cur int64, newSpec, oldSpec *appsv1.DeploymentSpec) int64 { return cur + 1 } -func (s *ClusterState) deleteDeployment(w http.ResponseWriter, namespace, name string) { +func (s *ClusterState) deleteDeployment(w http.ResponseWriter, r *http.Request, namespace, name string) { key := deploymentKey(namespace, name) s.mu.Lock() @@ -284,6 +320,12 @@ func (s *ClusterState) deleteDeployment(w http.ResponseWriter, namespace, name s return } + if isDryRun(r) { + writeJSON(w, http.StatusOK, dep.DeepCopy()) + + return + } + delete(s.deployments, key) // Cascade: garbage-collect the Pods this Deployment owns. s.garbageCollectLocked(dep.UID) diff --git a/services/kubernetes/deployment_rs.go b/services/kubernetes/deployment_rs.go new file mode 100644 index 00000000..721ed630 --- /dev/null +++ b/services/kubernetes/deployment_rs.go @@ -0,0 +1,144 @@ +package kubernetes + +import ( + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +// Deployment → ReplicaSet → Pod interposition. Real Deployments don't own Pods +// directly — they own a ReplicaSet per pod-template revision, and the ReplicaSet +// owns the Pods. Materializing the intermediate ReplicaSet makes `kubectl get +// rs` and owner-reference-walking operators behave like a real cluster, and a +// pod-template change becomes a new ReplicaSet (a real rolling update) rather +// than an in-place pod swap. Cascade GC already walks Deployment→RS→Pod, so +// deletion needs no change. + +// syncDeploymentReplicaSetLocked reconciles the Deployment's current-revision +// ReplicaSet (creating/updating it and pruning stale revisions), materializes +// its Pods, and returns the live replica count. Callers hold s.mu. +func (s *ClusterState) syncDeploymentReplicaSetLocked(dep *appsv1.Deployment, desired int) int32 { + st := s.reg.getStore(apiGroupApps, "v1", "replicasets") + if st == nil { + // Registry unavailable (should not happen) — fall back to direct ownership. + return clampInt32(s.syncScaledPods(dep.Namespace, dep.Name, deploymentOwnerRef(dep), dep.Spec.Template, desired)) + } + + rsName := dep.Name + "-" + podTemplateHash(dep.Spec.Template) + + s.pruneStaleDeploymentRSLocked(st, dep, rsName) + + rs := s.upsertDeploymentRSLocked(st, dep, rsName, desired) + if rs == nil { + return 0 + } + + reconcileReplicaSet(s, rs) + st.stampRVLocked(rs) + st.watch.publish(EventModified, rs.GetNamespace(), *rs.DeepCopy()) + + ready, _, _ := unstructured.NestedInt64(rs.Object, "status", "replicas") + + return clampInt32(int(ready)) +} + +// upsertDeploymentRSLocked creates or updates the current-revision ReplicaSet, +// returning the stored object (or nil if it couldn't be built). +func (s *ClusterState) upsertDeploymentRSLocked( + st *registryStore, dep *appsv1.Deployment, rsName string, desired int, +) *unstructured.Unstructured { + key := objKey(dep.Namespace, rsName) + + if existing, ok := st.items[key]; ok { + _ = unstructured.SetNestedField(existing.Object, int64(desired), "spec", "replicas") + + return existing + } + + rs, err := buildDeploymentRSObject(dep, rsName, desired) + if err != nil { + return nil + } + + rs.SetUID(types.UID(newUID())) + rs.SetCreationTimestamp(s.now()) + st.items[key] = rs + st.watch.publish(EventAdded, rs.GetNamespace(), *rs.DeepCopy()) + + return rs +} + +// pruneStaleDeploymentRSLocked deletes ReplicaSets owned by dep whose name isn't +// the current revision — a rolling update retires the old revision's Pods via +// the normal owner cascade. +func (s *ClusterState) pruneStaleDeploymentRSLocked(st *registryStore, dep *appsv1.Deployment, keepName string) { + for key, rs := range st.items { + if rs.GetNamespace() != dep.Namespace || rs.GetName() == keepName { + continue + } + + if !ownedBy(rs.GetOwnerReferences(), dep.UID) { + continue + } + + delete(st.items, key) + st.bumpRVLocked() + s.garbageCollectLocked(rs.GetUID()) + st.watch.publish(EventDeleted, rs.GetNamespace(), *rs.DeepCopy()) + } +} + +// buildDeploymentRSObject renders a ReplicaSet unstructured from a Deployment's +// template + selector, owned (controller) by the Deployment. +func buildDeploymentRSObject(dep *appsv1.Deployment, rsName string, desired int) (*unstructured.Unstructured, error) { + tmpl, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&dep.Spec.Template) + if err != nil { + return nil, err + } + + var selector map[string]any + if dep.Spec.Selector != nil { + selector, _ = runtime.DefaultUnstructuredConverter.ToUnstructured(dep.Spec.Selector) + } + + rs := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "apps/v1", + "kind": "ReplicaSet", + "metadata": map[string]any{ + "name": rsName, + "namespace": dep.Namespace, + "labels": map[string]any{podTemplateHashLabel: podTemplateHash(dep.Spec.Template)}, + }, + "spec": map[string]any{ + "replicas": int64(desired), + "selector": selector, + "template": tmpl, + }, + }} + rs.SetOwnerReferences([]metav1.OwnerReference{deploymentOwnerRef(dep)}) + + return rs, nil +} + +func deploymentOwnerRef(dep *appsv1.Deployment) metav1.OwnerReference { + return metav1.OwnerReference{ + APIVersion: "apps/v1", Kind: "Deployment", Name: dep.Name, UID: dep.UID, + Controller: boolPtr(true), BlockOwnerDeletion: boolPtr(true), + } +} + +// clampInt32 narrows a reconciled count (already bounded by maxReconciledPods) to +// int32 for the status fields. +func clampInt32(n int) int32 { + if n < 0 { + return 0 + } + + if n > maxReconciledPods { + return maxReconciledPods + } + + return int32(n) +} diff --git a/services/kubernetes/deployment_rs_rollout_test.go b/services/kubernetes/deployment_rs_rollout_test.go new file mode 100644 index 00000000..7913b512 --- /dev/null +++ b/services/kubernetes/deployment_rs_rollout_test.go @@ -0,0 +1,159 @@ +// Test for Finding 10: docs/services.md §18 claims a Deployment pod-template +// change "creates a new ReplicaSet (a real rolling update) and retires the +// old one" — but no test asserted a second ReplicaSet actually gets created +// on a template change (only single-revision creation, in +// phase3_controllers_test.go's TestDeployment_InterposesReplicaSet, was +// covered). This exercises the roll-to-new-RS path end to end and records +// what "retires" actually means in the implementation (services/kubernetes/ +// deployment_rs.go: pruneStaleDeploymentRSLocked deletes the old ReplicaSet +// outright — it does not scale it to zero and keep it around for rollback). + +package kubernetes_test + +import ( + "net/http" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// listDeploymentReplicaSets fetches every ReplicaSet in the default namespace. +func listDeploymentReplicaSets(t *testing.T, base string) appsv1.ReplicaSetList { + t.Helper() + + resp := do(t, http.MethodGet, base+"/apis/apps/v1/namespaces/default/replicasets", nil) + defer resp.Body.Close() + + var list appsv1.ReplicaSetList + mustDecode(t, resp.Body, &list) + + return list +} + +// listDeploymentPods fetches every Pod in the default namespace. +func listDeploymentPods(t *testing.T, base string) corev1.PodList { + t.Helper() + + resp := do(t, http.MethodGet, base+"/api/v1/namespaces/default/pods", nil) + defer resp.Body.Close() + + var list corev1.PodList + mustDecode(t, resp.Body, &list) + + return list +} + +// TestDeployment_TemplateChangeRollsToNewReplicaSet drives the exact scenario +// §18 describes: create a Deployment, confirm it interposes exactly one +// ReplicaSet, then change the pod template (container image) and confirm a +// second, new-revision ReplicaSet is created, the old one is gone (deleted, +// not scaled to zero), and the Pods now belong to the new ReplicaSet. +func TestDeployment_TemplateChangeRollsToNewReplicaSet(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + dep := makeDeployment("web", 2) + + resp := do(t, http.MethodPost, base+"/apis/apps/v1/namespaces/default/deployments", mustJSON(t, dep)) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create: got %d", resp.StatusCode) + } + + resp.Body.Close() + + // Step 1: exactly one ReplicaSet exists for the initial revision. + initial := listDeploymentReplicaSets(t, base) + if len(initial.Items) != 1 { + t.Fatalf("initial RS count: got %d, want 1", len(initial.Items)) + } + + firstRS := initial.Items[0] + firstRSUID := firstRS.UID + + initialPods := listDeploymentPods(t, base) + if len(initialPods.Items) != 2 { + t.Fatalf("initial pod count: got %d, want 2", len(initialPods.Items)) + } + + for i := range initialPods.Items { + if !ownedByUID(initialPods.Items[i].OwnerReferences, firstRSUID) { + t.Fatalf("pod %s not owned by initial RS %s", initialPods.Items[i].Name, firstRSUID) + } + } + + // Step 2: change the pod template (a new image is a template change, just + // like a real `kubectl set image`), which must trigger a rolling update. + dep.Spec.Template.Spec.Containers[0].Image = "nginx:1.28" + + resp = do(t, http.MethodPut, base+"/apis/apps/v1/namespaces/default/deployments/web", mustJSON(t, dep)) + if resp.StatusCode != http.StatusOK { + t.Fatalf("update: got %d", resp.StatusCode) + } + + resp.Body.Close() + + // A second, new-revision ReplicaSet must now exist — and only it: the + // old RS is retired by deletion (pruneStaleDeploymentRSLocked), not by + // scaling to zero, so there is exactly one RS again, but it is NOT the + // same object as before. + afterRollout := listDeploymentReplicaSets(t, base) + if len(afterRollout.Items) != 1 { + t.Fatalf("post-rollout RS count: got %d, want 1 (old RS should be deleted, not kept at 0 replicas)", + len(afterRollout.Items)) + } + + newRS := afterRollout.Items[0] + if newRS.UID == firstRSUID { + t.Fatalf("post-rollout RS is the same object (UID %s) as the pre-rollout RS — no new ReplicaSet was created", + newRS.UID) + } + + if newRS.Name == firstRS.Name { + t.Fatalf("post-rollout RS name %q unchanged — template-hash naming did not roll", newRS.Name) + } + + // The old ReplicaSet is gone entirely (deleted, confirming "retires" in + // the docs means delete, not scale-to-zero-and-keep-for-rollback). + oldRS := do(t, http.MethodGet, base+"/apis/apps/v1/namespaces/default/replicasets/"+firstRS.Name, nil) + defer oldRS.Body.Close() + + if oldRS.StatusCode != http.StatusNotFound { + t.Fatalf("old RS %s: got %d, want 404 (deleted) — if this is ever 200, the docs' \"retires\" wording"+ + " (implying rollback support) becomes accurate and should stop being softened", firstRS.Name, oldRS.StatusCode) + } + + // Pods now belong to the new ReplicaSet, and only the new one. + afterPods := listDeploymentPods(t, base) + if len(afterPods.Items) != 2 { + t.Fatalf("post-rollout pod count: got %d, want 2", len(afterPods.Items)) + } + + for i := range afterPods.Items { + pod := afterPods.Items[i] + if !ownedByUID(pod.OwnerReferences, newRS.UID) { + t.Fatalf("pod %s not owned by new RS %s", pod.Name, newRS.UID) + } + + if ownedByUID(pod.OwnerReferences, firstRSUID) { + t.Fatalf("pod %s still owned by the retired RS %s", pod.Name, firstRSUID) + } + + if pod.Spec.Containers[0].Image != "nginx:1.28" { + t.Fatalf("pod %s image: got %q, want nginx:1.28", pod.Name, pod.Spec.Containers[0].Image) + } + } +} + +// ownedByUID reports whether refs contains an owner reference to uid. +func ownedByUID(refs []metav1.OwnerReference, uid types.UID) bool { + for _, ref := range refs { + if ref.UID == uid { + return true + } + } + + return false +} diff --git a/services/kubernetes/discovery.go b/services/kubernetes/discovery.go index 35093244..05879a50 100644 --- a/services/kubernetes/discovery.go +++ b/services/kubernetes/discovery.go @@ -25,7 +25,7 @@ import ( // // Returns false when the path is not a discovery request, so the caller falls // through to normal resource routing. -func (*ClusterState) serveDiscovery(w http.ResponseWriter, r *http.Request) bool { +func (s *ClusterState) serveDiscovery(w http.ResponseWriter, r *http.Request) bool { if r.Method != http.MethodGet { return false } @@ -45,7 +45,7 @@ func (*ClusterState) serveDiscovery(w http.ResponseWriter, r *http.Request) bool case "/apis": groups := make([]map[string]any, 0) - for _, gv := range discoveryGroups() { + for _, gv := range s.discoveryGroups() { groups = append(groups, apiGroup(gv.group, gv.version)) } @@ -58,7 +58,7 @@ func (*ClusterState) serveDiscovery(w http.ResponseWriter, r *http.Request) bool return true case "/api/v1": - writeJSON(w, http.StatusOK, apiResourceList("", "v1", coreResources())) + writeJSON(w, http.StatusOK, apiResourceList("", "v1", s.coreResources())) return true @@ -81,7 +81,7 @@ func (*ClusterState) serveDiscovery(w http.ResponseWriter, r *http.Request) bool // Group-version discovery: /apis//. Derived from the // registry (plus the typed apps/policy groups) so every served group and // its resources — including subresources — are advertised. - if res, gv, group, ok := groupVersionDiscovery(r.URL.Path); ok { + if res, gv, group, ok := s.groupVersionDiscovery(r.URL.Path); ok { writeJSON(w, http.StatusOK, apiResourceList(group, gv, res)) return true @@ -96,14 +96,24 @@ type groupVersion struct { version string } -// discoveryGroups lists the non-core API groups the server serves, each with a -// representative version, built from the typed handlers (apps, policy) plus the -// registry so new groups surface automatically. -func discoveryGroups() []groupVersion { - seen := map[string]bool{"apps": true, "policy": true} - out := []groupVersion{{"apps", "v1"}, {"policy", "v1"}} +// discoveryGroups (method) advertises groups from the LIVE registry, so a CRD +// created at runtime surfaces its group in discovery immediately. +func (s *ClusterState) discoveryGroups() []groupVersion { + return discoveryGroupsFrom(s.reg.allDefs()) +} + +// discoveryGroupsFrom lists the non-core API groups the server serves, each with +// a representative version, built from the typed handlers (apps, policy), the +// aggregated metrics.k8s.io and authorization.k8s.io APIs, plus the supplied +// defs so new groups surface automatically. +func discoveryGroupsFrom(defs []*resourceDef) []groupVersion { + seen := map[string]bool{"apps": true, "policy": true, apiGroupMetrics: true, apiGroupAuthorization: true} + out := []groupVersion{ + {"apps", "v1"}, {"policy", "v1"}, + {apiGroupMetrics, apiVersionMetrics}, {apiGroupAuthorization, apiVersionV1}, + } - for _, d := range registeredResources() { + for _, d := range defs { if d.group == "" || seen[d.group] { continue } @@ -117,8 +127,9 @@ func discoveryGroups() []groupVersion { } // groupVersionDiscovery returns the resource list for a /apis// -// path, or ok=false if the path isn't a served group-version. -func groupVersionDiscovery(path string) (res []apiResource, groupVersionStr, group string, ok bool) { +// path, or ok=false if the path isn't a served group-version. Reads the live +// registry so CRD group-versions resolve. +func (s *ClusterState) groupVersionDiscovery(path string) (res []apiResource, groupVersionStr, group string, ok bool) { parts := splitPath(strings.TrimSuffix(path, "/")) if len(parts) != 3 || parts[0] != pathSegAPIs { return nil, "", "", false @@ -128,11 +139,13 @@ func groupVersionDiscovery(path string) (res []apiResource, groupVersionStr, gro switch { case group == apiGroupApps && version == apiVersionV1: - return appsResources(), "apps/v1", apiGroupApps, true + return s.appsResources(), "apps/v1", apiGroupApps, true case group == apiGroupPolicy && version == apiVersionV1: return policyResources(), "policy/v1", apiGroupPolicy, true + case group == apiGroupAuthorization && version == apiVersionV1: + return authorizationResources(), apiGroupAuthorization + "/v1", apiGroupAuthorization, true default: - r := registryAPIResources(group, version) + r := registryAPIResourcesFrom(s.reg.allDefs(), group, version) if len(r) == 0 { return nil, "", "", false } @@ -198,8 +211,14 @@ func rwVerbs() []string { return []string{"get", "list", "watch", "create", "update", "patch", "delete"} } -func coreResources() []apiResource { - reg := registryAPIResources("", "v1") +// coreResources (method) reads the live registry so CRD core-group kinds (rare, +// but allowed) surface. coreResourcesFrom is the pure form OpenAPI uses. +func (s *ClusterState) coreResources() []apiResource { + return coreResourcesFrom(s.reg.allDefs()) +} + +func coreResourcesFrom(defs []*resourceDef) []apiResource { + reg := registryAPIResourcesFrom(defs, "", "v1") const typedCoreKinds = 7 @@ -225,8 +244,7 @@ func coreResources() []apiResource { // into discovery entries (the resource plus its /status and /scale // subresources), so discovery is derived from the registry and can't drift // from what the server actually serves. -func registryAPIResources(group, version string) []apiResource { - defs := registeredResources() +func registryAPIResourcesFrom(defs []*resourceDef, group, version string) []apiResource { out := make([]apiResource, 0, len(defs)) for _, d := range defs { @@ -259,20 +277,21 @@ func subresourceVerbs() []string { return []string{"get", "patch", "update"} } // //nolint:gochecknoglobals // immutable package-level lookup table. var registryShortNames = map[string][]string{ - "persistentvolumeclaims": {"pvc"}, - "persistentvolumes": {"pv"}, - "horizontalpodautoscalers": {"hpa"}, - "statefulsets": {"sts"}, - "replicasets": {"rs"}, - "daemonsets": {"ds"}, - "cronjobs": {"cj"}, - "ingresses": {"ing"}, - "networkpolicies": {"netpol"}, - "storageclasses": {"sc"}, - "resourcequotas": {"quota"}, - "limitranges": {"limits"}, - "events": {"ev"}, - "nodes": {"no"}, + "persistentvolumeclaims": {"pvc"}, + "persistentvolumes": {"pv"}, + "horizontalpodautoscalers": {"hpa"}, + "statefulsets": {"sts"}, + "replicasets": {"rs"}, + "daemonsets": {"ds"}, + "cronjobs": {"cj"}, + "ingresses": {"ing"}, + "networkpolicies": {"netpol"}, + "storageclasses": {"sc"}, + "resourcequotas": {"quota"}, + "limitranges": {"limits"}, + "events": {"ev"}, + "nodes": {"no"}, + "customresourcedefinitions": {"crd", "crds"}, } func policyResources() []apiResource { @@ -287,8 +306,14 @@ func policyResources() []apiResource { } } -func appsResources() []apiResource { - reg := registryAPIResources(apiGroupApps, "v1") +// appsResources (method) reads the live registry. appsResourcesFrom is the pure +// form OpenAPI uses. +func (s *ClusterState) appsResources() []apiResource { + return appsResourcesFrom(s.reg.allDefs()) +} + +func appsResourcesFrom(defs []*resourceDef) []apiResource { + reg := registryAPIResourcesFrom(defs, apiGroupApps, "v1") const typedAppsEntries = 3 diff --git a/services/kubernetes/dryrun.go b/services/kubernetes/dryrun.go new file mode 100644 index 00000000..f99fb0a4 --- /dev/null +++ b/services/kubernetes/dryrun.go @@ -0,0 +1,17 @@ +package kubernetes + +import "net/http" + +// dryRunQueryValue is the value kubectl / client-go send for a server-side +// dry-run (`?dryRun=All`, from `kubectl apply|create|delete --dry-run=server`). +const dryRunQueryValue = "All" + +// isDryRun reports whether the request is a server-side dry-run. A dry-run write +// runs the same name/namespace/conflict validation and defaulting as a real +// write, then echoes the object the server would have stored WITHOUT persisting +// it, bumping any resourceVersion counter, running reconcile, or emitting watch +// events. Controllers do not run during a real apiserver dry-run either, so +// skipping reconcile keeps the echoed object faithful (no synthetic status). +func isDryRun(r *http.Request) bool { + return r.URL.Query().Get("dryRun") == dryRunQueryValue +} diff --git a/services/kubernetes/endpoints.go b/services/kubernetes/endpoints.go index 3238bd78..cc14965b 100644 --- a/services/kubernetes/endpoints.go +++ b/services/kubernetes/endpoints.go @@ -4,7 +4,6 @@ import ( "net/http" "sort" "strings" - "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -39,7 +38,7 @@ func (s *ClusterState) serveEndpoints(w http.ResponseWriter, r *http.Request, ro return } - s.listEndpointsAllNamespaces(w) + s.listEndpointsAllNamespaces(w, r) return } @@ -78,27 +77,37 @@ func (s *ClusterState) serveEndpointsCollection(w http.ResponseWriter, r *http.R return } - s.listEndpoints(w, namespace) + s.listEndpoints(w, r, namespace) } -func (s *ClusterState) listEndpoints(w http.ResponseWriter, namespace string) { +func (s *ClusterState) listEndpoints(w http.ResponseWriter, r *http.Request, namespace string) { s.mu.RLock() defer s.mu.RUnlock() - items := s.collectEndpointsLocked(namespace) + items, cont, ok := listPage(s.collectEndpointsLocked(namespace), w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &corev1.EndpointsList{ TypeMeta: metav1.TypeMeta{Kind: "EndpointsList", APIVersion: "v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } -func (s *ClusterState) listEndpointsAllNamespaces(w http.ResponseWriter) { +func (s *ClusterState) listEndpointsAllNamespaces(w http.ResponseWriter, r *http.Request) { s.mu.RLock() defer s.mu.RUnlock() - items := s.collectEndpointsLocked("") + items, cont, ok := listPage(s.collectEndpointsLocked(""), w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &corev1.EndpointsList{ TypeMeta: metav1.TypeMeta{Kind: "EndpointsList", APIVersion: "v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } @@ -153,14 +162,14 @@ func endpointsKey(namespace, name string) string { // Subsets is left empty — there's no scheduler / Pod-IP allocation in Wave 2. // Real apiserver lets the endpoints controller fill Subsets in once Pods // match the Service selector and become Ready. -func newEndpointsObject(namespace, name string) *corev1.Endpoints { +func (s *ClusterState) newEndpointsObject(namespace, name string) *corev1.Endpoints { return &corev1.Endpoints{ TypeMeta: metav1.TypeMeta{Kind: "Endpoints", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, UID: types.UID(newUID()), - CreationTimestamp: metav1.NewTime(time.Now()), + CreationTimestamp: s.now(), ResourceVersion: "1", }, } diff --git a/services/kubernetes/eviction.go b/services/kubernetes/eviction.go new file mode 100644 index 00000000..d905e9cd --- /dev/null +++ b/services/kubernetes/eviction.go @@ -0,0 +1,204 @@ +package kubernetes + +import ( + "fmt" + "io" + "math" + "net/http" + + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// subresourceEviction is the pods subresource a real apiserver serves at +// POST .../pods/{name}/eviction (policy/v1 Eviction). It has no group of its +// own — it hangs off the core Pod resource, same as /log and /exec would. +const subresourceEviction = "eviction" + +// evictPod handles POST .../namespaces/{ns}/pods/{name}/eviction: it deletes +// the named Pod unless doing so would violate a PodDisruptionBudget whose +// selector matches it, in which case it responds 429 Too Many Requests and +// leaves the Pod in place — mirroring the real apiserver's eviction handler. +func (s *ClusterState) evictPod(w http.ResponseWriter, r *http.Request, namespace, name string) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, "k8s api: pods/eviction: method not allowed: "+r.Method) + + return + } + + // The Eviction body (policy/v1 Eviction, carrying only ObjectMeta and + // optional DeleteOptions) adds nothing this handler needs — namespace and + // name already come from the URL — but real clients send one, so it must + // be drained rather than left to leak the connection. + _, _ = io.Copy(io.Discard, r.Body) + + s.mu.Lock() + defer s.mu.Unlock() + + key := podKey(namespace, name) + + pod, ok := s.pods[key] + if !ok { + writeNotFound(w, "k8s api: pod not found: "+key) + + return + } + + if status := s.checkPDBAllowsEvictionLocked(namespace, pod); status != nil { + writeJSON(w, http.StatusTooManyRequests, status) + + return + } + + delete(s.pods, key) + s.resyncEndpointsForNamespaceLocked(namespace) + s.wPods.publish(EventDeleted, namespace, *pod.DeepCopy()) + + writeJSON(w, http.StatusOK, &metav1.Status{ + TypeMeta: metav1.TypeMeta{Kind: "Status", APIVersion: "v1"}, + Status: metav1.StatusSuccess, + Code: http.StatusOK, + Message: "eviction complete", + }) +} + +// checkPDBAllowsEvictionLocked returns a non-nil 429 Status if evicting pod +// would violate a PodDisruptionBudget in namespace whose selector matches it. +// Every matching PDB's status is refreshed with the current observed counts +// regardless of the outcome, mirroring what the (absent) disruption +// controller would compute. Callers hold s.mu. +func (s *ClusterState) checkPDBAllowsEvictionLocked(namespace string, pod *corev1.Pod) *metav1.Status { + var blocked *metav1.Status + + for _, pdb := range s.pdbs { + if pdb.Namespace != namespace || pdb.Spec.Selector == nil { + continue + } + + sel, err := metav1.LabelSelectorAsSelector(pdb.Spec.Selector) + if err != nil || !sel.Matches(labels.Set(pod.Labels)) { + continue + } + + expected, healthy := s.matchingPodCountsLocked(namespace, sel) + desiredHealthy, allowed := disruptionBudget(pdb, expected, healthy) + + updatePDBStatusLocked(pdb, healthy, desiredHealthy, allowed, expected) + + if allowed <= 0 && blocked == nil { + blocked = pdbBlockedStatus(pdb.Name, desiredHealthy, healthy) + } + } + + return blocked +} + +// matchingPodCountsLocked returns, among namespace's non-terminal Pods +// matching sel: expected (the total count) and healthy (those Running and +// Ready) — the inputs a PodDisruptionBudget's status is computed from. +// Callers hold s.mu. +func (s *ClusterState) matchingPodCountsLocked(namespace string, sel labels.Selector) (expected, healthy int) { + for _, p := range s.pods { + if p.Namespace != namespace || p.Status.Phase == corev1.PodSucceeded || p.Status.Phase == corev1.PodFailed { + continue + } + + if !sel.Matches(labels.Set(p.Labels)) { + continue + } + + expected++ + + if p.Status.Phase == corev1.PodRunning && podReady(p) { + healthy++ + } + } + + return expected, healthy +} + +func podReady(p *corev1.Pod) bool { + for _, c := range p.Status.Conditions { + if c.Type == corev1.PodReady { + return c.Status == corev1.ConditionTrue + } + } + + return false +} + +// disruptionBudget resolves a PDB's minAvailable/maxUnavailable (int or +// percent of expected) into the desired healthy count and how many further +// disruptions are currently allowed against it. +func disruptionBudget(pdb *policyv1.PodDisruptionBudget, expected, healthy int) (desiredHealthy, allowed int) { + switch { + case pdb.Spec.MinAvailable != nil: + v, err := intstr.GetScaledValueFromIntOrPercent(pdb.Spec.MinAvailable, expected, true) + if err != nil { + v = expected + } + + desiredHealthy = v + case pdb.Spec.MaxUnavailable != nil: + v, err := intstr.GetScaledValueFromIntOrPercent(pdb.Spec.MaxUnavailable, expected, false) + if err != nil { + v = 0 + } + + desiredHealthy = expected - v + default: + desiredHealthy = 0 + } + + return desiredHealthy, healthy - desiredHealthy +} + +// updatePDBStatusLocked mirrors the observed counts onto the PDB's status, the +// same fields the (absent) disruption controller would keep in sync. Callers +// hold s.mu. +func updatePDBStatusLocked(pdb *policyv1.PodDisruptionBudget, healthy, desiredHealthy, allowed, expected int) { + pdb.Status.CurrentHealthy = int32(healthy) //nolint:gosec // pod counts are far below int32 range + pdb.Status.DesiredHealthy = int32(desiredHealthy) //nolint:gosec // pod counts are far below int32 range + pdb.Status.ExpectedPods = int32(expected) //nolint:gosec // pod counts are far below int32 range + pdb.Status.DisruptionsAllowed = clampToInt32(allowed) + pdb.Status.ObservedGeneration = pdb.Generation +} + +// clampToInt32 narrows a disruption count to int32, clamping to [0, +// math.MaxInt32]. allowed can go negative when a PDB is already violated +// (more disruptions have happened than the budget permits), which the 429 +// decision in checkPDBAllowsEvictionLocked relies on seeing as "no budget +// left" rather than a negative DisruptionsAllowed on the wire — matching +// what a real PodDisruptionBudgetStatus reports. The upper clamp makes the +// int->int32 narrowing a deliberate, bound-checked conversion instead of +// gosec G115's unchecked one (mirrors safeInt32 in server/aws/eks/operations.go). +func clampToInt32(v int) int32 { + switch { + case v < 0: + return 0 + case v > math.MaxInt32: + return math.MaxInt32 + default: + return int32(v) + } +} + +// pdbBlockedStatus builds the 429 Status a real apiserver returns when an +// eviction would violate a PodDisruptionBudget. +func pdbBlockedStatus(pdbName string, desiredHealthy, healthy int) *metav1.Status { + msg := fmt.Sprintf( + "Cannot evict pod as it would violate the pod's disruption budget %q: needs %d healthy pods, has %d", + pdbName, desiredHealthy, healthy, + ) + + return &metav1.Status{ + TypeMeta: metav1.TypeMeta{Kind: "Status", APIVersion: "v1"}, + Status: metav1.StatusFailure, + Code: http.StatusTooManyRequests, + Reason: metav1.StatusReasonTooManyRequests, + Message: msg, + } +} diff --git a/services/kubernetes/eviction_test.go b/services/kubernetes/eviction_test.go new file mode 100644 index 00000000..99def302 --- /dev/null +++ b/services/kubernetes/eviction_test.go @@ -0,0 +1,118 @@ +package kubernetes_test + +import ( + "net/http" + "testing" + + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func createWebPod(t *testing.T, base, name string) { + t.Helper() + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{"app": "web"}}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app", Image: "nginx:1.27"}}}, + } + + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/pods", mustJSON(t, pod)) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create pod %s: got %d, want 201", name, resp.StatusCode) + } + + resp.Body.Close() +} + +func createWebPDB(t *testing.T, base string, minAvailable int32) { + t.Helper() + + pdb := &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{Name: "web-pdb"}, + Spec: policyv1.PodDisruptionBudgetSpec{ + MinAvailable: &intstr.IntOrString{Type: intstr.Int, IntVal: minAvailable}, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "web"}}, + }, + } + + resp := do(t, http.MethodPost, base+"/apis/policy/v1/namespaces/default/poddisruptionbudgets", mustJSON(t, pdb)) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create pdb: got %d, want 201", resp.StatusCode) + } + + resp.Body.Close() +} + +// TestEviction_BlockedByPDB pins that evicting a Pod which would drop the +// matching set below the PDB's minAvailable is rejected with 429, and the +// Pod is left in place. +func TestEviction_BlockedByPDB(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + createWebPDB(t, base, 2) + createWebPod(t, base, "web-1") + createWebPod(t, base, "web-2") + + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/pods/web-1/eviction", nil) + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("evict status: got %d, want 429", resp.StatusCode) + } + + var status metav1.Status + mustDecode(t, resp.Body, &status) + + if status.Reason != metav1.StatusReasonTooManyRequests { + t.Fatalf("status reason: got %q, want TooManyRequests", status.Reason) + } + + // The Pod must still be there — the eviction was refused, not merely + // reported as refused. + resp = do(t, http.MethodGet, base+"/api/v1/namespaces/default/pods/web-1", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("pod after blocked eviction: got %d, want 200", resp.StatusCode) + } + + resp.Body.Close() +} + +// TestEviction_Allowed pins that evicting a Pod within the PDB's budget +// deletes it, the same as a plain DELETE would. +func TestEviction_Allowed(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + createWebPDB(t, base, 1) + createWebPod(t, base, "web-1") + createWebPod(t, base, "web-2") + + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/pods/web-1/eviction", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("evict status: got %d, want 200", resp.StatusCode) + } + + resp.Body.Close() + + resp = do(t, http.MethodGet, base+"/api/v1/namespaces/default/pods/web-1", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("pod after allowed eviction: got %d, want 404", resp.StatusCode) + } + + resp.Body.Close() +} + +// TestEviction_NotFoundPod pins the 404 path for evicting a Pod that doesn't +// exist. +func TestEviction_NotFoundPod(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/pods/ghost/eviction", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("evict ghost pod: got %d, want 404", resp.StatusCode) + } + + resp.Body.Close() +} diff --git a/services/kubernetes/finalizers.go b/services/kubernetes/finalizers.go new file mode 100644 index 00000000..a452d4fe --- /dev/null +++ b/services/kubernetes/finalizers.go @@ -0,0 +1,55 @@ +package kubernetes + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// Finalizer-gated deletion. A real apiserver does not remove an object that +// carries metadata.finalizers on DELETE: it stamps metadata.deletionTimestamp +// and leaves the object in place (Terminating) until a controller removes the +// last finalizer, at which point the object is actually deleted. These helpers +// implement that on both the typed and registry paths. + +// markForDeletion stamps deletionTimestamp (once) when meta carries finalizers +// and returns true, signaling the caller to persist the now-Terminating object +// and emit MODIFIED instead of removing it. Returns false when there are no +// finalizers, so the caller deletes immediately. +func (s *ClusterState) markForDeletion(meta *metav1.ObjectMeta) bool { + if len(meta.Finalizers) == 0 { + return false + } + + if meta.DeletionTimestamp == nil { + t := s.now() + meta.DeletionTimestamp = &t + } + + return true +} + +// finalizersDrained reports whether a Terminating object (deletionTimestamp set) +// has had its last finalizer removed and should now be garbage-collected. +func finalizersDrained(meta *metav1.ObjectMeta) bool { + return meta.DeletionTimestamp != nil && len(meta.Finalizers) == 0 +} + +// markForDeletionUnstructured is the registry-path equivalent of markForDeletion. +func (s *ClusterState) markForDeletionUnstructured(obj *unstructured.Unstructured) bool { + if len(obj.GetFinalizers()) == 0 { + return false + } + + if obj.GetDeletionTimestamp() == nil { + t := s.now() + obj.SetDeletionTimestamp(&t) + } + + return true +} + +// finalizersDrainedUnstructured is the registry-path equivalent of +// finalizersDrained. +func finalizersDrainedUnstructured(obj *unstructured.Unstructured) bool { + return obj.GetDeletionTimestamp() != nil && len(obj.GetFinalizers()) == 0 +} diff --git a/services/kubernetes/hpa.go b/services/kubernetes/hpa.go new file mode 100644 index 00000000..7e33deac --- /dev/null +++ b/services/kubernetes/hpa.go @@ -0,0 +1,277 @@ +package kubernetes + +import ( + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const ( + // defaultHPAMinReplicas mirrors the apiserver default when spec.minReplicas + // is omitted from a HorizontalPodAutoscaler. + defaultHPAMinReplicas = 1 + // hpaTargetKindDeployment is the only scaleTargetRef kind actuated today. + hpaTargetKindDeployment = "Deployment" + // hpaMetricTypeResource / hpaResourceCPU select the Resource CPU metric, + // the common HPA case cloudemu samples from the metrics.k8s.io source. + hpaMetricTypeResource = "Resource" + hpaResourceCPU = "cpu" + // hpaUtilizationPercent turns a usage/request ratio into a percentage, the + // unit spec.metrics[].resource.target.averageUtilization is expressed in. + hpaUtilizationPercent = 100 +) + +// reconcileHPA actuates a HorizontalPodAutoscaler against its scale target. +// When the spec carries a Resource CPU averageUtilization metric, it samples +// the target's Pods from the same metrics.k8s.io source `kubectl top` reads +// and applies the real HPA ratio, +// +// desiredReplicas = ceil(currentReplicas * currentUtilization / targetUtilization), +// +// clamped into [minReplicas, maxReplicas]. Without a usable metric (no metric +// configured, no matching Pods, or Pods with no CPU request) it falls back to +// clamping the current replica count into bounds — real HPA reports "unknown" +// and holds rather than scaling on missing data. Only Deployment targets are +// actuated; other kinds are left unchanged. Runs under s.mu (called from the +// registry create/update/patch path). +func reconcileHPA(s *ClusterState, obj *unstructured.Unstructured) { + kind, name := hpaScaleTarget(obj) + if kind != hpaTargetKindDeployment || name == "" { + return + } + + dep, ok := s.deployments[deploymentKey(obj.GetNamespace(), name)] + if !ok { + // Target not found: leave status untouched rather than fabricating + // numbers for a Deployment that doesn't exist. + return + } + + minReplicas, maxReplicas := hpaReplicaBounds(obj) + current := deploymentReplicas(dep) + + desired, utilization, metricDriven := s.hpaComputeDesired(obj, dep, current, minReplicas, maxReplicas) + + if desired != current { + s.applyHPAScale(dep, desired) + } + + setHPAStatus(obj, int64(dep.Status.Replicas), int64(desired), s.now()) + + if metricDriven { + setHPACurrentCPUMetric(obj, utilization) + } +} + +// hpaComputeDesired returns the replica count the HPA wants for dep. When a +// Resource CPU metric is configured and the target's Pods yield a utilization +// sample it applies the HPA ratio; otherwise it falls back to a min/max clamp +// of current. metricDriven reports whether a real sample drove the result. +func (s *ClusterState) hpaComputeDesired( + obj *unstructured.Unstructured, dep *appsv1.Deployment, current, minReplicas, maxReplicas int, +) (desired int, utilization int64, metricDriven bool) { + target, ok := hpaCPUTargetUtilization(obj) + if !ok { + return clampReplicas(current, minReplicas, maxReplicas), 0, false + } + + pods := s.hpaTargetPodsLocked(dep) + + util, ok := averageCPUUtilization(pods) + if !ok || len(pods) == 0 { + return clampReplicas(current, minReplicas, maxReplicas), 0, false + } + + scaled := scaleFromMetric(len(pods), util, target) + + return clampReplicas(scaled, minReplicas, maxReplicas), util, true +} + +// applyHPAScale writes desired onto the Deployment, re-reconciles it (so Pods +// and status converge to the new count) and publishes the update. Callers hold +// s.mu. +func (s *ClusterState) applyHPAScale(dep *appsv1.Deployment, desired int) { + r := int32(desired) //nolint:gosec // bounded by maxReplicas, a user-supplied HPA spec field. + dep.Spec.Replicas = &r + dep.ResourceVersion = bumpResourceVersion(dep.ResourceVersion) + s.reconcileDeploymentLocked(dep) + s.wDeployments.publish(EventModified, dep.Namespace, *dep.DeepCopy()) +} + +// scaleFromMetric implements the core HPA ratio, +// ceil(currentReplicas * currentUtil / targetUtil), using integer math so the +// result is deterministic. A non-positive target (already filtered upstream) +// leaves the count unchanged rather than dividing by zero. +func scaleFromMetric(currentReplicas int, currentUtil, targetUtil int64) int { + if targetUtil <= 0 { + return currentReplicas + } + + num := int64(currentReplicas)*currentUtil + targetUtil - 1 + + return int(num / targetUtil) +} + +// averageCPUUtilization returns the aggregate CPU utilization percentage across +// pods — sum(usage)/sum(request)*100, matching how the real HPA computes a +// Resource utilization metric. Usage comes from the metrics.k8s.io source +// (podMetricCPUUsage per container); requests come from each container's +// resources.requests.cpu. Reports ok=false when there are no Pods or none +// declare a CPU request (utilization is then "unknown", as in real HPA). +func averageCPUUtilization(pods []*corev1.Pod) (int64, bool) { + usagePerContainer, err := resource.ParseQuantity(podMetricCPUUsage) + if err != nil { + return 0, false + } + + var totalUsage, totalRequest int64 + + for _, pod := range pods { + for i := range pod.Spec.Containers { + c := &pod.Spec.Containers[i] + totalUsage += usagePerContainer.MilliValue() + + if req, ok := c.Resources.Requests[corev1.ResourceCPU]; ok { + totalRequest += req.MilliValue() + } + } + } + + if totalRequest == 0 { + return 0, false + } + + return totalUsage * hpaUtilizationPercent / totalRequest, true +} + +// hpaTargetPodsLocked returns the Running Pods that back dep, matched by the +// Deployment's label selector (the set the metrics sample is averaged over). +// Callers hold s.mu. +func (s *ClusterState) hpaTargetPodsLocked(dep *appsv1.Deployment) []*corev1.Pod { + if dep.Spec.Selector == nil || len(dep.Spec.Selector.MatchLabels) == 0 { + return nil + } + + var pods []*corev1.Pod + + for _, pod := range s.pods { + if pod.Namespace != dep.Namespace || pod.Status.Phase != corev1.PodRunning { + continue + } + + if labelsMatch(dep.Spec.Selector.MatchLabels, pod.Labels) { + pods = append(pods, pod) + } + } + + return pods +} + +// hpaCPUTargetUtilization reads the target averageUtilization of the first +// Resource/cpu entry in spec.metrics. ok=false means no such metric is +// configured (the min/max-clamp fallback applies). +func hpaCPUTargetUtilization(obj *unstructured.Unstructured) (int64, bool) { + metrics, found, _ := unstructured.NestedSlice(obj.Object, "spec", "metrics") + if !found { + return 0, false + } + + for _, raw := range metrics { + m, ok := raw.(map[string]any) + if !ok { + continue + } + + if t, _, _ := unstructured.NestedString(m, "type"); t != hpaMetricTypeResource { + continue + } + + if n, _, _ := unstructured.NestedString(m, "resource", "name"); n != hpaResourceCPU { + continue + } + + if target, ok, _ := unstructured.NestedInt64(m, "resource", "target", "averageUtilization"); ok && target > 0 { + return target, true + } + } + + return 0, false +} + +// deploymentReplicas reads the Deployment's spec.replicas, defaulting to 1 when +// unset (matching apiserver defaulting). +func deploymentReplicas(dep *appsv1.Deployment) int { + if dep.Spec.Replicas != nil { + return int(*dep.Spec.Replicas) + } + + return 1 +} + +// hpaScaleTarget reads spec.scaleTargetRef.{kind,name}. +func hpaScaleTarget(obj *unstructured.Unstructured) (kind, name string) { + kind, _, _ = unstructured.NestedString(obj.Object, "spec", "scaleTargetRef", "kind") + name, _, _ = unstructured.NestedString(obj.Object, "spec", "scaleTargetRef", "name") + + return kind, name +} + +// hpaReplicaBounds reads spec.minReplicas (default defaultHPAMinReplicas) and +// spec.maxReplicas, clamping an inverted range (max < min) up to min so +// callers always get a valid [min, max] window. +func hpaReplicaBounds(obj *unstructured.Unstructured) (minReplicas, maxReplicas int) { + minReplicas = defaultHPAMinReplicas + if v, found, _ := unstructured.NestedInt64(obj.Object, "spec", "minReplicas"); found { + minReplicas = int(v) + } + + maxReplicas = minReplicas + if v, found, _ := unstructured.NestedInt64(obj.Object, "spec", "maxReplicas"); found { + maxReplicas = int(v) + } + + if maxReplicas < minReplicas { + maxReplicas = minReplicas + } + + return minReplicas, maxReplicas +} + +func clampReplicas(n, minReplicas, maxReplicas int) int { + switch { + case n < minReplicas: + return minReplicas + case n > maxReplicas: + return maxReplicas + default: + return n + } +} + +// setHPAStatus mirrors the current/desired replica counts and a +// lastScaleTime onto the HPA's status, matching what a real +// horizontal-pod-autoscaler controller reports. +func setHPAStatus(obj *unstructured.Unstructured, currentReplicas, desiredReplicas int64, now metav1.Time) { + _ = unstructured.SetNestedField(obj.Object, currentReplicas, "status", "currentReplicas") + _ = unstructured.SetNestedField(obj.Object, desiredReplicas, "status", "desiredReplicas") + _ = unstructured.SetNestedField(obj.Object, now.UTC().Format(time.RFC3339), "status", "lastScaleTime") +} + +// setHPACurrentCPUMetric records the sampled CPU utilization on +// status.currentMetrics, the field the real HPA controller populates so +// `kubectl get hpa` can show the observed vs target percentage. +func setHPACurrentCPUMetric(obj *unstructured.Unstructured, utilization int64) { + _ = unstructured.SetNestedSlice(obj.Object, []any{ + map[string]any{ + "type": hpaMetricTypeResource, + "resource": map[string]any{ + "name": hpaResourceCPU, + "current": map[string]any{"averageUtilization": utilization}, + }, + }, + }, "status", "currentMetrics") +} diff --git a/services/kubernetes/hpa_test.go b/services/kubernetes/hpa_test.go new file mode 100644 index 00000000..e7ed91ab --- /dev/null +++ b/services/kubernetes/hpa_test.go @@ -0,0 +1,342 @@ +package kubernetes_test + +import ( + "net/http" + "testing" + + appsv1 "k8s.io/api/apps/v1" +) + +func makeHPA(name, targetName string, minReplicas, maxReplicas int32) map[string]any { + return map[string]any{ + "apiVersion": "autoscaling/v2", + "kind": "HorizontalPodAutoscaler", + "metadata": map[string]any{"name": name}, + "spec": map[string]any{ + "scaleTargetRef": map[string]any{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "name": targetName, + }, + "minReplicas": minReplicas, + "maxReplicas": maxReplicas, + }, + } +} + +func TestHPA_ScalesDeployment(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + do(t, http.MethodPost, base+"/apis/apps/v1/namespaces/default/deployments", + mustJSON(t, makeDeployment("web", 1))).Body.Close() + + resp := do(t, http.MethodPost, base+"/apis/autoscaling/v2/namespaces/default/horizontalpodautoscalers", + mustJSON(t, makeHPA("web-hpa", "web", 3, 5))) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create hpa: got %d, want 201", resp.StatusCode) + } + + var hpa map[string]any + mustDecode(t, resp.Body, &hpa) + + status, _ := hpa["status"].(map[string]any) + if status["desiredReplicas"] != float64(3) { + t.Fatalf("hpa status.desiredReplicas: got %v, want 3", status["desiredReplicas"]) + } + + if status["currentReplicas"] != float64(3) { + t.Fatalf("hpa status.currentReplicas: got %v, want 3", status["currentReplicas"]) + } + + if status["lastScaleTime"] == nil || status["lastScaleTime"] == "" { + t.Fatalf("hpa status.lastScaleTime missing") + } + + resp = do(t, http.MethodGet, base+"/apis/apps/v1/namespaces/default/deployments/web", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("get deployment: got %d, want 200", resp.StatusCode) + } + + var dep appsv1.Deployment + mustDecode(t, resp.Body, &dep) + + if dep.Spec.Replicas == nil || *dep.Spec.Replicas != 3 { + t.Fatalf("deployment spec.replicas: got %v, want 3", dep.Spec.Replicas) + } + + if dep.Status.Replicas != 3 { + t.Fatalf("deployment status.replicas: got %d, want 3", dep.Status.Replicas) + } +} + +func TestHPA_CapsAboveMaxReplicas(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + do(t, http.MethodPost, base+"/apis/apps/v1/namespaces/default/deployments", + mustJSON(t, makeDeployment("api", 10))).Body.Close() + + do(t, http.MethodPost, base+"/apis/autoscaling/v2/namespaces/default/horizontalpodautoscalers", + mustJSON(t, makeHPA("api-hpa", "api", 1, 4))).Body.Close() + + resp := do(t, http.MethodGet, base+"/apis/apps/v1/namespaces/default/deployments/api", nil) + var dep appsv1.Deployment + mustDecode(t, resp.Body, &dep) + + if *dep.Spec.Replicas != 4 { + t.Fatalf("deployment spec.replicas: got %d, want 4 (capped)", *dep.Spec.Replicas) + } +} + +func TestHPA_LeavesReplicasUnchangedWithinBounds(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + do(t, http.MethodPost, base+"/apis/apps/v1/namespaces/default/deployments", + mustJSON(t, makeDeployment("steady", 3))).Body.Close() + + do(t, http.MethodPost, base+"/apis/autoscaling/v2/namespaces/default/horizontalpodautoscalers", + mustJSON(t, makeHPA("steady-hpa", "steady", 2, 5))).Body.Close() + + resp := do(t, http.MethodGet, base+"/apis/apps/v1/namespaces/default/deployments/steady", nil) + var dep appsv1.Deployment + mustDecode(t, resp.Body, &dep) + + if *dep.Spec.Replicas != 3 { + t.Fatalf("deployment spec.replicas: got %d, want 3 (already within bounds)", *dep.Spec.Replicas) + } +} + +func TestHPA_DefaultMinReplicas(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + do(t, http.MethodPost, base+"/apis/apps/v1/namespaces/default/deployments", + mustJSON(t, makeDeployment("nomin", 0))).Body.Close() + + // No minReplicas in the spec — the emulator should default it to 1. + hpa := map[string]any{ + "apiVersion": "autoscaling/v2", + "kind": "HorizontalPodAutoscaler", + "metadata": map[string]any{"name": "nomin-hpa"}, + "spec": map[string]any{ + "scaleTargetRef": map[string]any{"apiVersion": "apps/v1", "kind": "Deployment", "name": "nomin"}, + "maxReplicas": int32(4), + }, + } + + do(t, http.MethodPost, base+"/apis/autoscaling/v2/namespaces/default/horizontalpodautoscalers", + mustJSON(t, hpa)).Body.Close() + + resp := do(t, http.MethodGet, base+"/apis/apps/v1/namespaces/default/deployments/nomin", nil) + var dep appsv1.Deployment + mustDecode(t, resp.Body, &dep) + + if *dep.Spec.Replicas != 1 { + t.Fatalf("deployment spec.replicas: got %d, want 1 (default minReplicas)", *dep.Spec.Replicas) + } +} + +func TestHPA_TargetNotFoundDoesNotPanic(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + resp := do(t, http.MethodPost, base+"/apis/autoscaling/v2/namespaces/default/horizontalpodautoscalers", + mustJSON(t, makeHPA("ghost-hpa", "ghost", 2, 4))) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create hpa with missing target: got %d, want 201", resp.StatusCode) + } + + var hpa map[string]any + mustDecode(t, resp.Body, &hpa) + + if hpa["status"] != nil { + if status, ok := hpa["status"].(map[string]any); ok && status["desiredReplicas"] != nil { + t.Fatalf("status should be untouched for a missing target: got %v", status) + } + } +} + +func TestHPA_NonDeploymentTargetIsIgnored(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + hpa := map[string]any{ + "apiVersion": "autoscaling/v2", + "kind": "HorizontalPodAutoscaler", + "metadata": map[string]any{"name": "sts-hpa"}, + "spec": map[string]any{ + "scaleTargetRef": map[string]any{"apiVersion": "apps/v1", "kind": "StatefulSet", "name": "db"}, + "minReplicas": int32(2), + "maxReplicas": int32(4), + }, + } + + resp := do(t, http.MethodPost, base+"/apis/autoscaling/v2/namespaces/default/horizontalpodautoscalers", + mustJSON(t, hpa)) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create hpa: got %d, want 201", resp.StatusCode) + } +} + +// makeDeploymentWithCPURequest builds a Deployment whose single container +// declares a CPU request, so metric-driven HPA has a denominator to compute a +// utilization percentage against (usage is the fixed metrics.k8s.io sample). +func makeDeploymentWithCPURequest(name string, replicas int32, cpuRequest string) map[string]any { + return map[string]any{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]any{"name": name}, + "spec": map[string]any{ + "replicas": replicas, + "selector": map[string]any{"matchLabels": map[string]any{"app": name}}, + "template": map[string]any{ + "metadata": map[string]any{"labels": map[string]any{"app": name}}, + "spec": map[string]any{ + "containers": []any{ + map[string]any{ + "name": "main", + "image": "nginx:1.27", + "resources": map[string]any{"requests": map[string]any{"cpu": cpuRequest}}, + }, + }, + }, + }, + }, + } +} + +// makeCPUHPA builds an autoscaling/v2 HPA targeting a Deployment on a Resource +// CPU averageUtilization metric. +func makeCPUHPA(name, targetName string, minReplicas, maxReplicas, targetUtil int32) map[string]any { + return map[string]any{ + "apiVersion": "autoscaling/v2", + "kind": "HorizontalPodAutoscaler", + "metadata": map[string]any{"name": name}, + "spec": map[string]any{ + "scaleTargetRef": map[string]any{"apiVersion": "apps/v1", "kind": "Deployment", "name": targetName}, + "minReplicas": minReplicas, + "maxReplicas": maxReplicas, + "metrics": []any{ + map[string]any{ + "type": "Resource", + "resource": map[string]any{ + "name": "cpu", + "target": map[string]any{"type": "Utilization", "averageUtilization": targetUtil}, + }, + }, + }, + }, + } +} + +func getDeploymentReplicas(t *testing.T, base, name string) int32 { + t.Helper() + + resp := do(t, http.MethodGet, base+"/apis/apps/v1/namespaces/default/deployments/"+name, nil) + + var dep appsv1.Deployment + mustDecode(t, resp.Body, &dep) + + if dep.Spec.Replicas == nil { + t.Fatalf("deployment %s: spec.replicas is nil", name) + } + + return *dep.Spec.Replicas +} + +// Fixed metrics.k8s.io sample is 250m CPU per container. With a 100m request +// the utilization is 250%, well above a 50% target, so an HPA scales up toward +// ceil(N * 250/50) and is capped at maxReplicas. +func TestHPA_MetricScalesUpUnderLoad(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + do(t, http.MethodPost, base+"/apis/apps/v1/namespaces/default/deployments", + mustJSON(t, makeDeploymentWithCPURequest("hot", 2, "100m"))).Body.Close() + + resp := do(t, http.MethodPost, base+"/apis/autoscaling/v2/namespaces/default/horizontalpodautoscalers", + mustJSON(t, makeCPUHPA("hot-hpa", "hot", 1, 6, 50))) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create hpa: got %d, want 201", resp.StatusCode) + } + + var hpa map[string]any + mustDecode(t, resp.Body, &hpa) + + // ceil(2 * 250/50) = 10, capped at maxReplicas=6. + if got := getDeploymentReplicas(t, base, "hot"); got != 6 { + t.Fatalf("deployment spec.replicas: got %d, want 6 (scaled up, capped at max)", got) + } + + status, _ := hpa["status"].(map[string]any) + if status["desiredReplicas"] != float64(6) { + t.Fatalf("hpa status.desiredReplicas: got %v, want 6", status["desiredReplicas"]) + } + + metrics, ok := status["currentMetrics"].([]any) + if !ok || len(metrics) == 0 { + t.Fatalf("hpa status.currentMetrics missing: %v", status["currentMetrics"]) + } +} + +// A 1000m request against the fixed 250m sample is 25% utilization, below the +// 50% target, so the HPA scales down toward ceil(N * 25/50) but never past +// minReplicas. +func TestHPA_MetricScalesDownAndFloorsAtMin(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + do(t, http.MethodPost, base+"/apis/apps/v1/namespaces/default/deployments", + mustJSON(t, makeDeploymentWithCPURequest("cold", 4, "1000m"))).Body.Close() + + do(t, http.MethodPost, base+"/apis/autoscaling/v2/namespaces/default/horizontalpodautoscalers", + mustJSON(t, makeCPUHPA("cold-hpa", "cold", 3, 6, 50))).Body.Close() + + // ceil(4 * 25/50) = 2, floored at minReplicas=3. + if got := getDeploymentReplicas(t, base, "cold"); got != 3 { + t.Fatalf("deployment spec.replicas: got %d, want 3 (scaled down, floored at min)", got) + } +} + +// A 500m request against the fixed 250m sample is exactly the 50% target, so +// ceil(N * 50/50) = N leaves replicas unchanged. +func TestHPA_MetricAtTargetLeavesReplicasUnchanged(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + do(t, http.MethodPost, base+"/apis/apps/v1/namespaces/default/deployments", + mustJSON(t, makeDeploymentWithCPURequest("even", 3, "500m"))).Body.Close() + + do(t, http.MethodPost, base+"/apis/autoscaling/v2/namespaces/default/horizontalpodautoscalers", + mustJSON(t, makeCPUHPA("even-hpa", "even", 1, 6, 50))).Body.Close() + + if got := getDeploymentReplicas(t, base, "even"); got != 3 { + t.Fatalf("deployment spec.replicas: got %d, want 3 (at target, unchanged)", got) + } +} + +// With a CPU metric configured but no CPU request on the Pods, utilization is +// unknown — the HPA must not scale on missing data and must fall back to the +// min/max clamp (here capping 8 replicas at max=5), without panicking. +func TestHPA_MetricMissingFallsBackToClamp(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + // makeDeployment's container declares no resources.requests.cpu. + do(t, http.MethodPost, base+"/apis/apps/v1/namespaces/default/deployments", + mustJSON(t, makeDeployment("norq", 8))).Body.Close() + + resp := do(t, http.MethodPost, base+"/apis/autoscaling/v2/namespaces/default/horizontalpodautoscalers", + mustJSON(t, makeCPUHPA("norq-hpa", "norq", 1, 5, 50))) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create hpa: got %d, want 201", resp.StatusCode) + } + + resp.Body.Close() + + if got := getDeploymentReplicas(t, base, "norq"); got != 5 { + t.Fatalf("deployment spec.replicas: got %d, want 5 (fallback clamp to max)", got) + } +} diff --git a/services/kubernetes/kubectl_smoke_test.go b/services/kubernetes/kubectl_smoke_test.go new file mode 100644 index 00000000..dc3ec0a9 --- /dev/null +++ b/services/kubernetes/kubectl_smoke_test.go @@ -0,0 +1,143 @@ +//go:build kubectl + +// This file is gated behind the "kubectl" build tag, so it never runs as +// part of the normal `go test ./...` gate — it needs a real kubectl binary +// on PATH and is meant to be run explicitly: +// +// go test -tags kubectl ./services/kubernetes/... -run TestKubectlSmoke -v +// +// CI WIRING NOTE (not implemented here — this file intentionally does not +// touch .github/workflows): a workflow job that wants this test needs to +// install kubectl before invoking go test, e.g.: +// +// - uses: azure/setup-kubectl@v4 +// with: +// version: 'v1.29.0' +// - run: go test -tags kubectl ./services/kubernetes/... -run TestKubectlSmoke -v +// +// COVERAGE: the emulator here is served over plain HTTP via httptest.NewServer +// (no TLS), so this test drives real kubectl against it directly — discovery +// negotiation (kubectl refuses to proceed without a working /api, /apis, +// /version, and OpenAPI, all exercised implicitly by every command below), +// `kubectl get namespaces`, and a create+read round trip via `kubectl apply +// -f` / `kubectl get deploy` (client-side apply: GET, then POST-if-missing or +// PATCH-if-present, going through the same createDeployment/patchDeployment +// handlers the Go-client tests cover). +// +// NOT COVERED: TLS/certificate trust (the SDK-compat `serve` entrypoint's +// kubeconfigs point at an HTTPS endpoint backed by internal/k8spki — this +// harness uses a plain-HTTP kubeconfig instead, since kubectl doesn't need +// TLS to talk to a plain httptest server); ?watch=true streaming; and +// RBAC/NetworkPolicy evaluation (covered by rbac_test.go / networkpolicy_test.go). +package kubernetes_test + +import ( + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stackshy/cloudemu/v2/services/kubernetes" +) + +func TestKubectlSmoke(t *testing.T) { + kubectlPath, err := exec.LookPath("kubectl") + if err != nil { + t.Skip("kubectl not found on PATH; skipping kubectl CI smoke test") + } + + api := kubernetes.NewAPIServer() + ts := httptest.NewServer(api) + t.Cleanup(ts.Close) + api.SetBaseURL(ts.URL) + + uid, _ := api.RegisterCluster() + + kubeconfig := writeSmokeKubeconfig(t, ts.URL, uid) + + runKubectl(t, kubectlPath, kubeconfig, "version", "--client") + runKubectl(t, kubectlPath, kubeconfig, "get", "namespaces") + + manifest := writeSmokeDeploymentManifest(t) + runKubectl(t, kubectlPath, kubeconfig, "apply", "-f", manifest) + runKubectl(t, kubectlPath, kubeconfig, "get", "deploy", "-n", "default") +} + +// writeSmokeKubeconfig writes a minimal kubeconfig pointing at the emulator's +// plain-HTTP test server — no certificate-authority-data is needed since +// there's no TLS handshake to validate. +func writeSmokeKubeconfig(t *testing.T, baseURL, uid string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "kubeconfig.yaml") + yaml := "apiVersion: v1\n" + + "kind: Config\n" + + "clusters:\n" + + "- name: cloudemu\n" + + " cluster:\n" + + " server: " + baseURL + "/k8s/" + uid + "\n" + + "users:\n" + + "- name: cloudemu\n" + + " user:\n" + + " token: " + kubernetes.StubToken + "\n" + + "contexts:\n" + + "- name: cloudemu\n" + + " context:\n" + + " cluster: cloudemu\n" + + " user: cloudemu\n" + + " namespace: default\n" + + "current-context: cloudemu\n" + + if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { + t.Fatalf("write kubeconfig: %v", err) + } + + return path +} + +// writeSmokeDeploymentManifest writes a minimal Deployment manifest for +// `kubectl apply -f`. +func writeSmokeDeploymentManifest(t *testing.T) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "deploy.yaml") + manifest := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: smoke + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: smoke + template: + metadata: + labels: + app: smoke + spec: + containers: + - name: smoke + image: nginx:latest +` + + if err := os.WriteFile(path, []byte(manifest), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + + return path +} + +// runKubectl runs kubectl with --kubeconfig plus args, failing the test with +// combined output on error. +func runKubectl(t *testing.T, kubectlPath, kubeconfig string, args ...string) { + t.Helper() + + cmdArgs := append([]string{"--kubeconfig=" + kubeconfig}, args...) + + out, err := exec.Command(kubectlPath, cmdArgs...).CombinedOutput() + if err != nil { + t.Fatalf("kubectl %v: %v\n%s", args, err, out) + } +} diff --git a/services/kubernetes/limitrange.go b/services/kubernetes/limitrange.go new file mode 100644 index 00000000..473ab044 --- /dev/null +++ b/services/kubernetes/limitrange.go @@ -0,0 +1,161 @@ +package kubernetes + +import ( + "fmt" + "net/http" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +// applyLimitRange applies every namespace LimitRange of type Container to +// pod: containers missing a request/limit for a resource the LimitRange +// defaults get that default, and every container's resources are validated +// against the LimitRange's min/max. Returns a non-nil Status (403 Forbidden) +// on the first violation found, in which case the caller must abandon the +// create. Callers hold s.mu. +func (s *ClusterState) applyLimitRange(namespace string, pod *corev1.Pod) *metav1.Status { + store := s.reg.stores[regKey("", "v1", "limitranges")] + if store == nil { + return nil + } + + for _, obj := range store.items { + if obj.GetNamespace() != namespace { + continue + } + + if status := applyLimitRangeObject(obj, pod); status != nil { + return status + } + } + + return nil +} + +// applyLimitRangeObject applies a single LimitRange's Container-type items to +// every container of pod. +func applyLimitRangeObject(obj *unstructured.Unstructured, pod *corev1.Pod) *metav1.Status { + rawItems, found, err := unstructured.NestedSlice(obj.Object, "spec", "limits") + if err != nil || !found { + return nil + } + + for _, raw := range rawItems { + m, ok := raw.(map[string]any) + if !ok { + continue + } + + var item corev1.LimitRangeItem + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(m, &item); err != nil { + continue + } + + if item.Type != corev1.LimitTypeContainer { + continue + } + + for i := range pod.Spec.Containers { + c := &pod.Spec.Containers[i] + + applyContainerDefaults(c, &item) + + if status := validateContainerLimits(obj.GetName(), pod.Name, c, &item); status != nil { + return status + } + } + } + + return nil +} + +// applyContainerDefaults fills in c.Resources.Requests/Limits from item's +// default/defaultRequest for any resource the container did not itself set. +func applyContainerDefaults(c *corev1.Container, item *corev1.LimitRangeItem) { + if len(item.Default) > 0 { + if c.Resources.Limits == nil { + c.Resources.Limits = corev1.ResourceList{} + } + + for name, qty := range item.Default { + if _, ok := c.Resources.Limits[name]; !ok { + c.Resources.Limits[name] = qty + } + } + } + + if len(item.DefaultRequest) > 0 { + if c.Resources.Requests == nil { + c.Resources.Requests = corev1.ResourceList{} + } + + for name, qty := range item.DefaultRequest { + if _, ok := c.Resources.Requests[name]; !ok { + c.Resources.Requests[name] = qty + } + } + } +} + +// validateContainerLimits checks c's requests and limits against item's +// min/max, returning a 403 Forbidden Status on the first violation. +func validateContainerLimits(lrName, podName string, c *corev1.Container, item *corev1.LimitRangeItem) *metav1.Status { + for name, qty := range c.Resources.Requests { + if status := checkResourceBounds(lrName, podName, c.Name, "request", name, qty, item); status != nil { + return status + } + } + + for name, qty := range c.Resources.Limits { + if status := checkResourceBounds(lrName, podName, c.Name, "limit", name, qty, item); status != nil { + return status + } + } + + return nil +} + +func checkResourceBounds( + lrName, podName, containerName, kind string, name corev1.ResourceName, qty resource.Quantity, item *corev1.LimitRangeItem, +) *metav1.Status { + if minQty, ok := item.Min[name]; ok && qty.Cmp(minQty) < 0 { + return limitRangeViolationStatus(lrName, podName, containerName, kind, name, "minimum", minQty) + } + + if maxQty, ok := item.Max[name]; ok && qty.Cmp(maxQty) > 0 { + return limitRangeViolationStatus(lrName, podName, containerName, kind, name, "maximum", maxQty) + } + + return nil +} + +func limitRangeViolationStatus( + lrName, podName, containerName, kind string, resName corev1.ResourceName, bound string, boundQty resource.Quantity, +) *metav1.Status { + msg := fmt.Sprintf( + "pods %q is forbidden: limitrange %q: %s %s %s for container %q must be %s to %s", + podName, lrName, kind, resName, kind, containerName, boundRelation(bound), boundQty.String(), + ) + + return &metav1.Status{ + TypeMeta: metav1.TypeMeta{Kind: "Status", APIVersion: "v1"}, + Status: metav1.StatusFailure, + Code: http.StatusForbidden, + Reason: metav1.StatusReasonForbidden, + Message: msg, + } +} + +// boundRelation renders the "minimum"/"maximum" bound kind as the comparison +// phrase used in the violation message. +func boundRelation(bound string) string { + if bound == "minimum" { + return "greater than or equal" + } + + return "less than or equal" +} diff --git a/services/kubernetes/limitrange_test.go b/services/kubernetes/limitrange_test.go new file mode 100644 index 00000000..bb2b60e3 --- /dev/null +++ b/services/kubernetes/limitrange_test.go @@ -0,0 +1,102 @@ +package kubernetes_test + +import ( + "net/http" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestLimitRange_DefaultsApplied pins that a Container-type LimitRange's +// default is applied to a Pod created without an explicit cpu limit. +func TestLimitRange_DefaultsApplied(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + lr := &corev1.LimitRange{ + TypeMeta: metav1.TypeMeta{Kind: "LimitRange", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "defaults"}, + Spec: corev1.LimitRangeSpec{ + Limits: []corev1.LimitRangeItem{ + {Type: corev1.LimitTypeContainer, Default: corev1.ResourceList{"cpu": resource.MustParse("250m")}}, + }, + }, + } + + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/limitranges", mustJSON(t, lr)) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create limitrange: got %d, want 201", resp.StatusCode) + } + + resp.Body.Close() + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "web"}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app", Image: "nginx:1.27"}}}, + } + + resp = do(t, http.MethodPost, base+"/api/v1/namespaces/default/pods", mustJSON(t, pod)) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create pod: got %d, want 201", resp.StatusCode) + } + + var created corev1.Pod + mustDecode(t, resp.Body, &created) + + got, ok := created.Spec.Containers[0].Resources.Limits["cpu"] + if !ok { + t.Fatal("container cpu limit not defaulted") + } + + if got.String() != "250m" { + t.Fatalf("defaulted cpu limit: got %q, want 250m", got.String()) + } +} + +// TestLimitRange_MaxViolationRejected pins that a Pod requesting more than a +// LimitRange's max is rejected with 403, and never persisted. +func TestLimitRange_MaxViolationRejected(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + lr := &corev1.LimitRange{ + TypeMeta: metav1.TypeMeta{Kind: "LimitRange", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "caps"}, + Spec: corev1.LimitRangeSpec{ + Limits: []corev1.LimitRangeItem{ + {Type: corev1.LimitTypeContainer, Max: corev1.ResourceList{"cpu": resource.MustParse("500m")}}, + }, + }, + } + + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/limitranges", mustJSON(t, lr)) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create limitrange: got %d, want 201", resp.StatusCode) + } + + resp.Body.Close() + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "too-big"}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{ + Name: "app", Image: "nginx:1.27", + Resources: corev1.ResourceRequirements{Limits: corev1.ResourceList{"cpu": resource.MustParse("1")}}, + }}}, + } + + resp = do(t, http.MethodPost, base+"/api/v1/namespaces/default/pods", mustJSON(t, pod)) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("create over-limit pod: got %d, want 403", resp.StatusCode) + } + + resp.Body.Close() + + resp = do(t, http.MethodGet, base+"/api/v1/namespaces/default/pods/too-big", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("rejected pod persisted: got %d, want 404", resp.StatusCode) + } + + resp.Body.Close() +} diff --git a/services/kubernetes/metrics.go b/services/kubernetes/metrics.go new file mode 100644 index 00000000..ac352fe5 --- /dev/null +++ b/services/kubernetes/metrics.go @@ -0,0 +1,236 @@ +package kubernetes + +import ( + "net/http" + "sort" + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// metrics.k8s.io is an aggregated API in real Kubernetes (served by the +// metrics-server addon, not the core apiserver), so it doesn't fit the +// registry-backed resourceDef model: it has no persisted objects, only a +// point-in-time synthesis over live Pods and the synthetic Node. `kubectl +// top` is the primary consumer — without this endpoint it fails discovery +// before ever issuing a request. + +const ( + apiGroupMetrics = "metrics.k8s.io" + apiVersionMetrics = "v1beta1" + metricsAPIVersion = apiGroupMetrics + "/" + apiVersionMetrics + metricsAPIPrefix = "/apis/" + metricsAPIVersion + metricsResourcePods = "pods" + metricsResourceNodes = "nodes" + metricsWindow = "60s" + podMetricCPUUsage = "250m" + podMetricMemUsage = "64Mi" + nodeMetricCPUUsage = "500m" + nodeMetricMemUsage = "128Mi" +) + +// serveMetrics answers every /apis/metrics.k8s.io/v1beta1/... request: the +// group-version's own APIResourceList, and the pods/nodes metrics endpoints +// kubectl top reads. Values are fixed synthetic constants — there is no real +// resource usage to sample in an in-memory emulator. +func (s *ClusterState) serveMetrics(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, "k8s api: metrics.k8s.io: method not allowed: "+r.Method) + + return + } + + parts := splitPath(strings.TrimPrefix(r.URL.Path, metricsAPIPrefix)) + + if s.serveMetricsTopLevel(w, parts) { + return + } + + if namespace, name, ok := parseNamespacedPodMetricsPath(parts); ok { + if name == "" { + s.servePodMetricsList(w, namespace) + } else { + s.servePodMetricsItem(w, namespace, name) + } + + return + } + + writeNotFound(w, "k8s api: metrics.k8s.io: unrecognized path "+r.URL.Path) +} + +// serveMetricsTopLevel handles every shape except the namespaced Pod metrics +// paths: the group-version discovery document, the all-namespaces Pod +// metrics list, and the single synthetic Node's metrics (list or item). +// Reports whether it served parts. +func (s *ClusterState) serveMetricsTopLevel(w http.ResponseWriter, parts []string) bool { + switch { + case len(parts) == 0: + writeJSON(w, http.StatusOK, metricsAPIResourceList()) + case len(parts) == 1 && parts[0] == metricsResourcePods: + s.servePodMetricsList(w, "") + case len(parts) == 1 && parts[0] == metricsResourceNodes: + serveNodeMetricsList(w, s.now()) + case len(parts) == 2 && parts[0] == metricsResourceNodes: + serveNodeMetricsItem(w, parts[1], s.now()) + default: + return false + } + + return true +} + +// metricsPathSegsPodList and metricsPathSegsPodItem are the segment counts of +// "namespaces/{ns}/pods" and "namespaces/{ns}/pods/{name}" respectively. +const ( + metricsPathSegsPodList = 3 + metricsPathSegsPodItem = 4 +) + +// parseNamespacedPodMetricsPath matches the "namespaces/{ns}/pods" and +// "namespaces/{ns}/pods/{name}" path shapes. name is "" for the list form. +func parseNamespacedPodMetricsPath(parts []string) (namespace, name string, ok bool) { + if len(parts) == 0 || parts[0] != namespacesSegment { + return "", "", false + } + + switch len(parts) { + case metricsPathSegsPodList: + if parts[2] != metricsResourcePods { + return "", "", false + } + + return parts[1], "", true + case metricsPathSegsPodItem: + if parts[2] != metricsResourcePods { + return "", "", false + } + + return parts[1], parts[3], true + default: + return "", "", false + } +} + +// metricsAPIResourceList answers GET /apis/metrics.k8s.io/v1beta1 — the +// group-version discovery document naming the two resources this endpoint +// serves. +func metricsAPIResourceList() map[string]any { + return map[string]any{ + "kind": "APIResourceList", + "apiVersion": "v1", + "groupVersion": metricsAPIVersion, + "resources": []map[string]any{ + {"name": metricsResourcePods, "namespaced": true, "kind": "PodMetrics", "verbs": []string{"get", "list"}}, + {"name": metricsResourceNodes, "namespaced": false, "kind": "NodeMetrics", "verbs": []string{"get", "list"}}, + }, + } +} + +func (s *ClusterState) servePodMetricsList(w http.ResponseWriter, namespace string) { + s.mu.RLock() + items := s.collectPodMetricsLocked(namespace, s.now()) + s.mu.RUnlock() + + writeJSON(w, http.StatusOK, map[string]any{ + "apiVersion": metricsAPIVersion, + "kind": "PodMetricsList", + "items": items, + }) +} + +func (s *ClusterState) servePodMetricsItem(w http.ResponseWriter, namespace, name string) { + s.mu.RLock() + pod, ok := s.pods[podKey(namespace, name)] + + var obj map[string]any + if ok { + obj = podMetricsObject(pod, s.now()) + } + s.mu.RUnlock() + + if !ok { + writeNotFound(w, "k8s api: pod not found: "+namespace+"/"+name) + + return + } + + writeJSON(w, http.StatusOK, obj) +} + +// collectPodMetricsLocked returns PodMetrics for every live Pod in namespace +// ("" = all namespaces), sorted by "/" for a stable list +// order. Callers hold s.mu (at least RLock). +func (s *ClusterState) collectPodMetricsLocked(namespace string, now metav1.Time) []map[string]any { + keys := make([]string, 0, len(s.pods)) + + for k, pod := range s.pods { + if namespace == "" || pod.Namespace == namespace { + keys = append(keys, k) + } + } + + sort.Strings(keys) + + out := make([]map[string]any, 0, len(keys)) + for _, k := range keys { + out = append(out, podMetricsObject(s.pods[k], now)) + } + + return out +} + +// podMetricsObject synthesizes a PodMetrics object with one fixed-usage entry +// per container in pod. +func podMetricsObject(pod *corev1.Pod, now metav1.Time) map[string]any { + containers := make([]map[string]any, 0, len(pod.Spec.Containers)) + + for i := range pod.Spec.Containers { + c := &pod.Spec.Containers[i] + containers = append(containers, map[string]any{ + "name": c.Name, + "usage": map[string]any{"cpu": podMetricCPUUsage, "memory": podMetricMemUsage}, + }) + } + + return map[string]any{ + "apiVersion": metricsAPIVersion, + "kind": "PodMetrics", + "metadata": map[string]any{"name": pod.Name, "namespace": pod.Namespace}, + "timestamp": now, + "window": metricsWindow, + "containers": containers, + } +} + +func serveNodeMetricsList(w http.ResponseWriter, now metav1.Time) { + writeJSON(w, http.StatusOK, map[string]any{ + "apiVersion": metricsAPIVersion, + "kind": "NodeMetricsList", + "items": []map[string]any{nodeMetricsObject(now)}, + }) +} + +func serveNodeMetricsItem(w http.ResponseWriter, name string, now metav1.Time) { + if name != nodeName { + writeNotFound(w, "k8s api: node not found: "+name) + + return + } + + writeJSON(w, http.StatusOK, nodeMetricsObject(now)) +} + +// nodeMetricsObject synthesizes fixed usage for the single synthetic Node +// every emulated Pod is scheduled onto. +func nodeMetricsObject(now metav1.Time) map[string]any { + return map[string]any{ + "apiVersion": metricsAPIVersion, + "kind": "NodeMetrics", + "metadata": map[string]any{"name": nodeName}, + "timestamp": now, + "window": metricsWindow, + "usage": map[string]any{"cpu": nodeMetricCPUUsage, "memory": nodeMetricMemUsage}, + } +} diff --git a/services/kubernetes/metrics_test.go b/services/kubernetes/metrics_test.go new file mode 100644 index 00000000..7203ea90 --- /dev/null +++ b/services/kubernetes/metrics_test.go @@ -0,0 +1,179 @@ +package kubernetes_test + +import ( + "net/http" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMetricsEndpoint_PodMetrics(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + pod := &corev1.Pod{ + TypeMeta: metav1.TypeMeta{Kind: "Pod", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "web"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "nginx:1.27"}}, + }, + } + + do(t, http.MethodPost, base+"/api/v1/namespaces/default/pods", mustJSON(t, pod)).Body.Close() + + resp := do(t, http.MethodGet, base+"/apis/metrics.k8s.io/v1beta1/namespaces/default/pods/web", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("get pod metrics: got %d, want 200", resp.StatusCode) + } + + var got map[string]any + mustDecode(t, resp.Body, &got) + + if got["kind"] != "PodMetrics" { + t.Fatalf("kind: got %v, want PodMetrics", got["kind"]) + } + + containers, ok := got["containers"].([]any) + if !ok || len(containers) != 1 { + t.Fatalf("containers: got %v", got["containers"]) + } + + usage, ok := containers[0].(map[string]any)["usage"].(map[string]any) + if !ok || usage["cpu"] == "" || usage["memory"] == "" { + t.Fatalf("usage missing: got %v", containers[0]) + } + + // Namespace list should also surface the Pod. + resp = do(t, http.MethodGet, base+"/apis/metrics.k8s.io/v1beta1/namespaces/default/pods", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("list pod metrics: got %d, want 200", resp.StatusCode) + } + + var list map[string]any + mustDecode(t, resp.Body, &list) + + items, ok := list["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("items: got %v", list["items"]) + } + + // Cluster-wide pods and the single synthetic Node's metrics should also + // resolve, since `kubectl top pods -A` / `kubectl top nodes` hit these. + resp = do(t, http.MethodGet, base+"/apis/metrics.k8s.io/v1beta1/pods", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("all-ns pod metrics: got %d, want 200", resp.StatusCode) + } + + resp.Body.Close() + + resp = do(t, http.MethodGet, base+"/apis/metrics.k8s.io/v1beta1/nodes", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("node metrics list: got %d, want 200", resp.StatusCode) + } + + resp.Body.Close() +} + +func TestMetricsEndpoint_GroupDiscovery(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + resp := do(t, http.MethodGet, base+"/apis", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("apis: got %d, want 200", resp.StatusCode) + } + + var groups map[string]any + mustDecode(t, resp.Body, &groups) + + found := false + + for _, g := range groups["groups"].([]any) { + if g.(map[string]any)["name"] == "metrics.k8s.io" { + found = true + } + } + + if !found { + t.Fatalf("metrics.k8s.io not advertised in /apis: %v", groups["groups"]) + } + + resp = do(t, http.MethodGet, base+"/apis/metrics.k8s.io/v1beta1", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("group-version discovery: got %d, want 200", resp.StatusCode) + } + + resp.Body.Close() +} + +func TestMetricsEndpoint_NodeMetricsItem(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + resp := do(t, http.MethodGet, base+"/apis/metrics.k8s.io/v1beta1/nodes/cloudemu-node-0", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("node metrics item: got %d, want 200", resp.StatusCode) + } + + var got map[string]any + mustDecode(t, resp.Body, &got) + + if got["kind"] != "NodeMetrics" { + t.Fatalf("kind: got %v, want NodeMetrics", got["kind"]) + } + + usage, ok := got["usage"].(map[string]any) + if !ok || usage["cpu"] == "" || usage["memory"] == "" { + t.Fatalf("usage missing: got %v", got["usage"]) + } +} + +func TestMetricsEndpoint_NotFoundAndMethodNotAllowed(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + resp := do(t, http.MethodGet, base+"/apis/metrics.k8s.io/v1beta1/namespaces/default/pods/ghost", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("missing pod: got %d, want 404", resp.StatusCode) + } + + resp.Body.Close() + + resp = do(t, http.MethodGet, base+"/apis/metrics.k8s.io/v1beta1/nodes/ghost", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("missing node: got %d, want 404", resp.StatusCode) + } + + resp.Body.Close() + + resp = do(t, http.MethodPost, base+"/apis/metrics.k8s.io/v1beta1/pods", nil) + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("POST pods: got %d, want 405", resp.StatusCode) + } + + resp.Body.Close() + + resp = do(t, http.MethodGet, base+"/apis/metrics.k8s.io/v1beta1/widgets", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("unrecognized path: got %d, want 404", resp.StatusCode) + } + + resp.Body.Close() + + // Namespaced path whose trailing resource isn't "pods". + resp = do(t, http.MethodGet, base+"/apis/metrics.k8s.io/v1beta1/namespaces/default/services", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("wrong namespaced resource: got %d, want 404", resp.StatusCode) + } + + resp.Body.Close() + + // Bare "namespaces/{ns}" with no resource segment. + resp = do(t, http.MethodGet, base+"/apis/metrics.k8s.io/v1beta1/namespaces/default", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("namespace with no resource: got %d, want 404", resp.StatusCode) + } + + resp.Body.Close() +} diff --git a/services/kubernetes/namespace.go b/services/kubernetes/namespace.go index e5d343f0..dde1d129 100644 --- a/services/kubernetes/namespace.go +++ b/services/kubernetes/namespace.go @@ -4,7 +4,6 @@ import ( "net/http" "sort" "strings" - "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -39,7 +38,7 @@ func (s *ClusterState) serveNamespaceCollection(w http.ResponseWriter, r *http.R return } - s.listNamespaces(w) + s.listNamespaces(w, r) case http.MethodPost: s.createNamespace(w, r) default: @@ -56,7 +55,7 @@ func (s *ClusterState) serveNamespaceItem(w http.ResponseWriter, r *http.Request case http.MethodPatch: s.patchNamespace(w, r, name) case http.MethodDelete: - s.deleteNamespace(w, name) + s.deleteNamespace(w, r, name) default: writeMethodNotAllowed(w, "k8s api: namespace item: method not allowed: "+r.Method) } @@ -102,14 +101,21 @@ func (s *ClusterState) createNamespace(w http.ResponseWriter, r *http.Request) { return } - ns := newNamespaceObject(in.Name) + ns := s.newNamespaceObject(in.Name) ns.Labels = in.Labels ns.Annotations = in.Annotations + + if isDryRun(r) { + writeJSON(w, http.StatusCreated, ns) + + return + } + s.namespaces[in.Name] = ns // Real apiserver auto-creates a "default" ServiceAccount in every new // namespace. Mirror that so `kubectl --namespace=` finds an SA. - sa := newServiceAccountObject(in.Name, "default") + sa := s.newServiceAccountObject(in.Name, "default") s.serviceAccounts[serviceAccountKey(in.Name, "default")] = sa s.wNamespaces.publish(EventAdded, "", *ns.DeepCopy()) @@ -118,7 +124,7 @@ func (s *ClusterState) createNamespace(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, ns) } -func (s *ClusterState) listNamespaces(w http.ResponseWriter) { +func (s *ClusterState) listNamespaces(w http.ResponseWriter, r *http.Request) { s.mu.RLock() defer s.mu.RUnlock() @@ -136,8 +142,14 @@ func (s *ClusterState) listNamespaces(w http.ResponseWriter) { items = append(items, *s.namespaces[n].DeepCopy()) } + items, cont, ok := listPage(items, w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &corev1.NamespaceList{ TypeMeta: metav1.TypeMeta{Kind: "NamespaceList", APIVersion: "v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } @@ -182,11 +194,28 @@ func (s *ClusterState) updateNamespace(w http.ResponseWriter, r *http.Request, n in.CreationTimestamp = cur.CreationTimestamp in.ResourceVersion = bumpResourceVersion(cur.ResourceVersion) in.TypeMeta = cur.TypeMeta + // deletionTimestamp is server-owned — carry it forward so a finalizer-removing + // PUT can't resurrect a Terminating namespace. + in.DeletionTimestamp = cur.DeletionTimestamp if in.Status.Phase == "" { in.Status.Phase = corev1.NamespaceActive } + if isDryRun(r) { + writeJSON(w, http.StatusOK, &in) + + return + } + + // Last finalizer removed on a Terminating namespace → complete the delete. + if finalizersDrained(&in.ObjectMeta) { + s.deleteNamespaceLocked(&in) + writeJSON(w, http.StatusOK, &in) + + return + } + s.namespaces[name] = &in s.wNamespaces.publish(EventModified, "", *in.DeepCopy()) writeJSON(w, http.StatusOK, &in) @@ -209,12 +238,34 @@ func (s *ClusterState) patchNamespace(w http.ResponseWriter, r *http.Request, na } patched.ResourceVersion = bumpResourceVersion(cur.ResourceVersion) + // Server-owned metadata: a merge-patch nulling deletionTimestamp (RFC 7396) + // must not resurrect a Terminating namespace — carry it (and uid/creation) + // forward, mirroring updateNamespace. + patched.DeletionTimestamp = cur.DeletionTimestamp + patched.UID = cur.UID + patched.CreationTimestamp = cur.CreationTimestamp + + if isDryRun(r) { + writeJSON(w, http.StatusOK, patched) + + return + } + + // A patch removing the last finalizer from a Terminating namespace completes + // the delete (patch inherits cur's deletionTimestamp). + if finalizersDrained(&patched.ObjectMeta) { + s.deleteNamespaceLocked(patched) + writeJSON(w, http.StatusOK, patched) + + return + } + s.namespaces[name] = patched s.wNamespaces.publish(EventModified, "", *patched.DeepCopy()) writeJSON(w, http.StatusOK, patched) } -func (s *ClusterState) deleteNamespace(w http.ResponseWriter, name string) { +func (s *ClusterState) deleteNamespace(w http.ResponseWriter, r *http.Request, name string) { s.mu.Lock() defer s.mu.Unlock() @@ -225,57 +276,97 @@ func (s *ClusterState) deleteNamespace(w http.ResponseWriter, name string) { return } + if isDryRun(r) { + writeJSON(w, http.StatusOK, ns.DeepCopy()) + + return + } + + // Finalizer-gated deletion: a namespace with finalizers goes Terminating and + // is only removed once the last finalizer is dropped via update/patch. + if s.markForDeletion(&ns.ObjectMeta) { + ns.ResourceVersion = bumpResourceVersion(ns.ResourceVersion) + s.wNamespaces.publish(EventModified, "", *ns.DeepCopy()) + writeJSON(w, http.StatusOK, ns.DeepCopy()) + + return + } + + s.deleteNamespaceLocked(ns) + writeJSON(w, http.StatusOK, ns.DeepCopy()) +} + +// deleteNamespaceLocked removes a namespace and cascades to every namespaced +// resource keyed under it. Each helper publishes a DELETED event so Watch +// subscribers see the cascade alongside the namespace going away. Callers hold +// s.mu. +func (s *ClusterState) deleteNamespaceLocked(ns *corev1.Namespace) { + name := ns.Name + delete(s.namespaces, name) s.wNamespaces.publish(EventDeleted, "", *ns.DeepCopy()) - // Cascading delete: drop every namespaced resource keyed under this - // namespace. Each helper publishes a DELETED event so Watch subscribers - // see the cascade alongside the namespace going away. prefix := name + "/" - cascadeDeleteWithEvents(s.configMaps, prefix, name, s.wConfigMaps) - cascadeDeleteWithEvents(s.pods, prefix, name, s.wPods) - cascadeDeleteWithEvents(s.secrets, prefix, name, s.wSecrets) - cascadeDeleteWithEvents(s.serviceAccounts, prefix, name, s.wServiceAccounts) - cascadeDeleteWithEvents(s.services, prefix, name, s.wServices) - cascadeDeleteWithEvents(s.deployments, prefix, name, s.wDeployments) - cascadeDeleteWithEvents(s.endpoints, prefix, name, s.wEndpoints) - - writeJSON(w, http.StatusOK, ns.DeepCopy()) + cascadeDeleteWithEvents(s, s.configMaps, prefix, name, s.wConfigMaps) + cascadeDeleteWithEvents(s, s.pods, prefix, name, s.wPods) + cascadeDeleteWithEvents(s, s.secrets, prefix, name, s.wSecrets) + cascadeDeleteWithEvents(s, s.serviceAccounts, prefix, name, s.wServiceAccounts) + cascadeDeleteWithEvents(s, s.services, prefix, name, s.wServices) + cascadeDeleteWithEvents(s, s.deployments, prefix, name, s.wDeployments) + cascadeDeleteWithEvents(s, s.endpoints, prefix, name, s.wEndpoints) } // deepCopier constrains the element type of a per-resource map: it must be // a pointer whose underlying type has the upstream Kubernetes-codegen -// DeepCopy() *V method. Every corev1/appsv1 type we store satisfies this, -// so cascadeDeleteWithEvents can publish its own copy of every event -// instead of aliasing the stored object's inner maps to all subscribers. +// DeepCopy() *V method and the metav1.Object metadata accessors. Every +// corev1/appsv1 type we store satisfies this, so cascadeDeleteWithEvents can +// publish its own copy of every event (instead of aliasing the stored object's +// inner maps to all subscribers) and honor per-child finalizers. type deepCopier[V any] interface { *V + metav1.Object DeepCopy() *V } -// cascadeDeleteWithEvents drops every entry in m whose key starts with -// prefix and publishes a DELETED Watch event for each removed object. -// Each event ships a freshly DeepCopy()'d value so subscribers see their -// own independent copy of the inner maps/slices — mutation by one -// subscriber can't corrupt another's view. -func cascadeDeleteWithEvents[V any, P deepCopier[V]](m map[string]P, prefix, ns string, b *broadcaster) { +// cascadeDeleteWithEvents removes (or, for finalizer-bearing children, marks +// Terminating) every entry in m whose key starts with prefix. A child carrying +// finalizers is not hard-deleted: it gets a deletionTimestamp and a MODIFIED +// event, matching a normal finalizer-gated delete, and is only reaped once its +// finalizers drain. Finalizer-free children are deleted and get a DELETED event. +// Each event ships a freshly DeepCopy()'d value so subscribers see their own +// independent copy of the inner maps/slices. +func cascadeDeleteWithEvents[V any, P deepCopier[V]](s *ClusterState, m map[string]P, prefix, ns string, b *broadcaster) { for k, v := range m { - if strings.HasPrefix(k, prefix) { - b.publish(EventDeleted, ns, *v.DeepCopy()) - delete(m, k) + if !strings.HasPrefix(k, prefix) { + continue } + + if len(v.GetFinalizers()) > 0 { + if v.GetDeletionTimestamp() == nil { + t := s.now() + v.SetDeletionTimestamp(&t) + } + + v.SetResourceVersion(bumpResourceVersion(v.GetResourceVersion())) + b.publish(EventModified, ns, *v.DeepCopy()) + + continue + } + + b.publish(EventDeleted, ns, *v.DeepCopy()) + delete(m, k) } } // newNamespaceObject builds a fresh Namespace with the implicit fields a // real apiserver fills in on creation (UID, creationTimestamp, status). -func newNamespaceObject(name string) *corev1.Namespace { +func (s *ClusterState) newNamespaceObject(name string) *corev1.Namespace { return &corev1.Namespace{ TypeMeta: metav1.TypeMeta{Kind: "Namespace", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{ Name: name, UID: types.UID(newUID()), - CreationTimestamp: metav1.NewTime(time.Now()), + CreationTimestamp: s.now(), ResourceVersion: "1", }, Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}, diff --git a/services/kubernetes/networkpolicy.go b/services/kubernetes/networkpolicy.go new file mode 100644 index 00000000..e3361fd7 --- /dev/null +++ b/services/kubernetes/networkpolicy.go @@ -0,0 +1,207 @@ +package kubernetes + +// networkpolicy.go implements a QUERY API for NetworkPolicy — NOT live-traffic +// enforcement. The emulator has no packet path: Pods never actually send +// bytes to each other, so there is nothing for a NetworkPolicy to intercept +// in real time. EvaluateNetworkPolicy instead answers "would a real cluster's +// CNI allow this connection?" against whatever NetworkPolicy objects are +// currently stored, for tests (and future topology.Engine wiring — see +// topology.CanConnect for the analogous VPC/security-group query) that want +// to assert on network segmentation without a live cluster. +// +// Semantics (ingress-only; NetworkPolicy also has an Egress side that this +// query does not evaluate): +// - No NetworkPolicy in the namespace selects the destination pod's labels +// with a non-empty Ingress rule list -> default allow (matches real +// Kubernetes: a Pod with no applicable NetworkPolicy accepts all traffic). +// - At least one such policy selects the destination -> allowed only if +// some ingress rule of some selecting policy matches both the source +// (via the rule's "from" peers) and the port/protocol. +// +// Simplification: the query takes a single namespace for both source and +// destination (there's no cross-namespace traffic model here), so a peer's +// namespaceSelector is matched against that one namespace's own labels +// rather than a distinct source namespace. A peer's ipBlock is never +// evaluated (the query has no IP to check it against) and never matches. + +import ( + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" +) + +// EvaluateNetworkPolicy reports whether traffic from a pod with srcPodLabels +// to a pod with dstPodLabels, both in namespace, on port/proto would be +// allowed by the NetworkPolicy objects currently stored for that namespace. +// port is a container port number; proto is "TCP"/"UDP"/"SCTP" ("" matches +// any protocol, mirroring an empty NetworkPolicyPort list matching everything). +// +// This is a query, not enforcement: no Pod-to-Pod traffic actually flows +// through the emulator, so nothing here blocks or allows real bytes. +func (s *ClusterState) EvaluateNetworkPolicy( + namespace string, srcPodLabels, dstPodLabels map[string]string, port int32, proto string, +) bool { + s.mu.RLock() + defer s.mu.RUnlock() + + policies := s.selectingIngressPoliciesLocked(namespace, dstPodLabels) + if len(policies) == 0 { + return true + } + + nsLabels := s.namespaceLabelsLocked(namespace) + + for i := range policies { + if ingressAllows(policies[i].Spec.Ingress, srcPodLabels, nsLabels, port, proto) { + return true + } + } + + return false +} + +// namespaceLabelsLocked returns the labels of namespace, or nil if it isn't +// found or carries none. Callers hold s.mu. +func (s *ClusterState) namespaceLabelsLocked(namespace string) map[string]string { + ns, ok := s.namespaces[namespace] + if !ok { + return nil + } + + return ns.Labels +} + +// selectingIngressPoliciesLocked returns every NetworkPolicy in namespace +// whose podSelector matches dstLabels and which declares at least one +// Ingress rule (a policy with no Ingress rules doesn't isolate ingress +// traffic for this query's purposes). Callers hold s.mu. +func (s *ClusterState) selectingIngressPoliciesLocked(namespace string, dstLabels map[string]string) []networkingv1.NetworkPolicy { + st := s.reg.stores[regKey(apiGroupNetworking, "v1", "networkpolicies")] + if st == nil { + return nil + } + + out := make([]networkingv1.NetworkPolicy, 0, len(st.items)) + + for _, obj := range st.items { + if obj.GetNamespace() != namespace { + continue + } + + var np networkingv1.NetworkPolicy + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &np); err != nil { + continue + } + + if len(np.Spec.Ingress) == 0 { + continue + } + + if !selectorMatches(&np.Spec.PodSelector, dstLabels) { + continue + } + + out = append(out, np) + } + + return out +} + +// ingressAllows reports whether any of the ingress rules permits traffic +// from srcLabels (in a namespace carrying nsLabels) on port/proto. +func ingressAllows(rules []networkingv1.NetworkPolicyIngressRule, srcLabels, nsLabels map[string]string, port int32, proto string) bool { + for _, rule := range rules { + if !portMatches(rule.Ports, port, proto) { + continue + } + + if len(rule.From) == 0 { + return true + } + + for _, peer := range rule.From { + if peerMatchesSrc(peer, srcLabels, nsLabels) { + return true + } + } + } + + return false +} + +// peerMatchesSrc reports whether a NetworkPolicyPeer selects the source pod. +// An ipBlock peer never matches (no IP is available to test against). A +// peer with neither podSelector nor namespaceSelector nor ipBlock is +// malformed and matches nothing. +func peerMatchesSrc(peer networkingv1.NetworkPolicyPeer, srcLabels, nsLabels map[string]string) bool { + if peer.IPBlock != nil { + return false + } + + switch { + case peer.PodSelector != nil && peer.NamespaceSelector != nil: + return selectorMatches(peer.NamespaceSelector, nsLabels) && selectorMatches(peer.PodSelector, srcLabels) + case peer.PodSelector != nil: + return selectorMatches(peer.PodSelector, srcLabels) + case peer.NamespaceSelector != nil: + return selectorMatches(peer.NamespaceSelector, nsLabels) + default: + return false + } +} + +// portMatches reports whether port/proto is covered by ports. An empty +// ports list matches everything, matching NetworkPolicyIngressRule's +// "if this field is empty then this rule matches all ports" semantics. +func portMatches(ports []networkingv1.NetworkPolicyPort, port int32, proto string) bool { + if len(ports) == 0 { + return true + } + + for _, p := range ports { + if p.Protocol != nil && proto != "" && string(*p.Protocol) != proto { + continue + } + + if portValueMatches(p, port) { + return true + } + } + + return false +} + +// portValueMatches reports whether a single NetworkPolicyPort covers port. +// An unset Port matches every port for the (already-checked) protocol; a set +// Port matches exactly, or — with EndPort set — matches the inclusive range. +func portValueMatches(p networkingv1.NetworkPolicyPort, port int32) bool { + if p.Port == nil { + return true + } + + start := p.Port.IntVal + if p.EndPort != nil { + return port >= start && port <= *p.EndPort + } + + return port == start +} + +// selectorMatches reports whether lbls satisfies sel (matchLabels and +// matchExpressions both honored via metav1.LabelSelectorAsSelector). A nil +// selector is treated as "no restriction" — callers only pass nil for +// NetworkPolicyPeer fields where nilness is meaningful on its own (see +// peerMatchesSrc), never for the required, non-pointer spec.podSelector. +func selectorMatches(sel *metav1.LabelSelector, lbls map[string]string) bool { + if sel == nil { + return true + } + + s, err := metav1.LabelSelectorAsSelector(sel) + if err != nil { + return false + } + + return s.Matches(labels.Set(lbls)) +} diff --git a/services/kubernetes/networkpolicy_test.go b/services/kubernetes/networkpolicy_test.go new file mode 100644 index 00000000..966166ba --- /dev/null +++ b/services/kubernetes/networkpolicy_test.go @@ -0,0 +1,184 @@ +package kubernetes_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stackshy/cloudemu/v2/services/kubernetes" +) + +func TestNetworkPolicy_DefaultAllowAndSelectiveDeny(t *testing.T) { + api := kubernetes.NewAPIServer() + uid, state := api.RegisterCluster() + ts := httptest.NewServer(api) + t.Cleanup(ts.Close) + api.SetBaseURL(ts.URL) + + base := ts.URL + "/k8s/" + uid + + srcLabels := map[string]string{"role": "other"} + dstLabels := map[string]string{"app": "web"} + + // No NetworkPolicy exists yet: default allow. + if !state.EvaluateNetworkPolicy("default", srcLabels, dstLabels, 80, "TCP") { + t.Error("expected default allow with no NetworkPolicy") + } + + policyBody := mustJSON(t, map[string]any{ + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + "metadata": map[string]any{"name": "web-policy"}, + "spec": map[string]any{ + "podSelector": map[string]any{"matchLabels": map[string]any{"app": "web"}}, + "ingress": []map[string]any{ + {"from": []map[string]any{ + {"podSelector": map[string]any{"matchLabels": map[string]any{"role": "allowed"}}}, + }}, + }, + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/networking.k8s.io/v1/namespaces/default/networkpolicies", policyBody) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create NetworkPolicy: status %d", resp.StatusCode) + } + resp.Body.Close() + + // A policy now selects the destination; a source that doesn't match the + // ingress rule's "from" selector must be denied. + if state.EvaluateNetworkPolicy("default", srcLabels, dstLabels, 80, "TCP") { + t.Error("expected deny: src labels don't match the policy's from selector") + } + + // A source that does match the "from" selector is allowed. + allowedSrc := map[string]string{"role": "allowed"} + if !state.EvaluateNetworkPolicy("default", allowedSrc, dstLabels, 80, "TCP") { + t.Error("expected allow: src labels match the policy's from selector") + } + + // A pod not selected by any policy's podSelector is unaffected (default + // allow still applies to it). + unselectedDst := map[string]string{"app": "other"} + if !state.EvaluateNetworkPolicy("default", srcLabels, unselectedDst, 80, "TCP") { + t.Error("expected default allow for a pod not selected by any NetworkPolicy") + } +} + +func TestNetworkPolicy_NamespaceSelectorPeer(t *testing.T) { + api := kubernetes.NewAPIServer() + uid, state := api.RegisterCluster() + ts := httptest.NewServer(api) + t.Cleanup(ts.Close) + api.SetBaseURL(ts.URL) + + base := ts.URL + "/k8s/" + uid + + policyBody := mustJSON(t, map[string]any{ + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + "metadata": map[string]any{"name": "ns-and-pod-policy"}, + "spec": map[string]any{ + "podSelector": map[string]any{"matchLabels": map[string]any{"app": "web"}}, + "ingress": []map[string]any{ + // Peer 1: namespaceSelector only (empty selector = all namespaces), + // so it matches regardless of pod labels. + {"from": []map[string]any{{"namespaceSelector": map[string]any{}}}}, + }, + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/networking.k8s.io/v1/namespaces/default/networkpolicies", policyBody) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create NetworkPolicy: status %d", resp.StatusCode) + } + resp.Body.Close() + + dstLabels := map[string]string{"app": "web"} + anySrc := map[string]string{"anything": "goes"} + + if !state.EvaluateNetworkPolicy("default", anySrc, dstLabels, 80, "TCP") { + t.Error("expected allow: empty namespaceSelector peer matches every namespace") + } +} + +func TestNetworkPolicy_PodAndNamespaceSelectorPeer(t *testing.T) { + api := kubernetes.NewAPIServer() + uid, state := api.RegisterCluster() + ts := httptest.NewServer(api) + t.Cleanup(ts.Close) + api.SetBaseURL(ts.URL) + + base := ts.URL + "/k8s/" + uid + + policyBody := mustJSON(t, map[string]any{ + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + "metadata": map[string]any{"name": "ns-pod-combo-policy"}, + "spec": map[string]any{ + "podSelector": map[string]any{"matchLabels": map[string]any{"app": "web"}}, + "ingress": []map[string]any{ + {"from": []map[string]any{ + { + "podSelector": map[string]any{"matchLabels": map[string]any{"role": "allowed"}}, + "namespaceSelector": map[string]any{}, + }, + }}, + }, + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/networking.k8s.io/v1/namespaces/default/networkpolicies", policyBody) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create NetworkPolicy: status %d", resp.StatusCode) + } + resp.Body.Close() + + dstLabels := map[string]string{"app": "web"} + + if state.EvaluateNetworkPolicy("default", map[string]string{"role": "other"}, dstLabels, 80, "TCP") { + t.Error("expected deny: src pod labels don't satisfy the combined peer's podSelector") + } + + if !state.EvaluateNetworkPolicy("default", map[string]string{"role": "allowed"}, dstLabels, 80, "TCP") { + t.Error("expected allow: src pod labels satisfy the combined peer's podSelector") + } +} + +func TestNetworkPolicy_PortRestriction(t *testing.T) { + api := kubernetes.NewAPIServer() + uid, state := api.RegisterCluster() + ts := httptest.NewServer(api) + t.Cleanup(ts.Close) + api.SetBaseURL(ts.URL) + + base := ts.URL + "/k8s/" + uid + + policyBody := mustJSON(t, map[string]any{ + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + "metadata": map[string]any{"name": "port-policy"}, + "spec": map[string]any{ + "podSelector": map[string]any{"matchLabels": map[string]any{"app": "web"}}, + "ingress": []map[string]any{ + {"ports": []map[string]any{{"protocol": "TCP", "port": 80}}}, + }, + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/networking.k8s.io/v1/namespaces/default/networkpolicies", policyBody) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create NetworkPolicy: status %d", resp.StatusCode) + } + resp.Body.Close() + + dstLabels := map[string]string{"app": "web"} + + if !state.EvaluateNetworkPolicy("default", nil, dstLabels, 80, "TCP") { + t.Error("expected allow on the permitted port") + } + + if state.EvaluateNetworkPolicy("default", nil, dstLabels, 443, "TCP") { + t.Error("expected deny on a port the rule doesn't list") + } +} diff --git a/services/kubernetes/openapi.go b/services/kubernetes/openapi.go index e1eafeb2..ab3a1577 100644 --- a/services/kubernetes/openapi.go +++ b/services/kubernetes/openapi.go @@ -112,7 +112,7 @@ func openAPIV3Root() map[string]any { "api/v1": groupV3Ref("api/v1"), } - for _, gv := range discoveryGroups() { + for _, gv := range discoveryGroupsFrom(registeredResources()) { p := "apis/" + gv.group + "/" + gv.version paths[p] = groupV3Ref(p) } @@ -172,15 +172,17 @@ func parseGVPath(p string) (group, version string) { func kindsForGroupVersion(group, version string) []string { var res []apiResource + defs := registeredResources() + switch { case group == "" && version == apiVersionV1: - res = coreResources() + res = coreResourcesFrom(defs) case group == apiGroupApps && version == apiVersionV1: - res = appsResources() + res = appsResourcesFrom(defs) case group == apiGroupPolicy && version == apiVersionV1: res = policyResources() default: - res = registryAPIResources(group, version) + res = registryAPIResourcesFrom(defs, group, version) } seen := map[string]bool{} diff --git a/services/kubernetes/pagination.go b/services/kubernetes/pagination.go new file mode 100644 index 00000000..fb015e2f --- /dev/null +++ b/services/kubernetes/pagination.go @@ -0,0 +1,124 @@ +package kubernetes + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "sort" + "strconv" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// keySeparator joins namespace and name into the sort key every list path +// orders by (the same "namespace/name" form as the store's map keys). +const keySeparator = "/" + +// k8sPageToken is the opaque continue-token payload for a chunked list request. +// It anchors resume to the last-emitted object's key instead of a positional +// offset, so an insert or delete before that key can no longer shift an offset +// into a skipped or duplicated item across the lock-free gap between page +// fetches. It is base64(JSON) and stays opaque to clients. +type k8sPageToken struct { + LastKey string `json:"k"` +} + +func encodePageToken(lastKey string) string { + data, _ := json.Marshal(k8sPageToken{LastKey: lastKey}) + + // URL-safe base64 so the token survives a query string without percent- + // encoding (StdEncoding's '+' would be decoded back to a space). + return base64.URLEncoding.EncodeToString(data) +} + +func decodePageToken(raw string) (k8sPageToken, error) { + data, err := base64.URLEncoding.DecodeString(raw) + if err != nil { + return k8sPageToken{}, err + } + + var t k8sPageToken + if err := json.Unmarshal(data, &t); err != nil { + return k8sPageToken{}, err + } + + return t, nil +} + +// objectKey returns the "namespace/name" sort key for a list element. In a +// range &items[i] is addressable and every k8s API list element (corev1.Pod, +// appsv1.Deployment, unstructured.Unstructured, …) satisfies metav1.Object via +// its pointer. ok is false when the element does not, in which case the caller +// falls back to an unpaginated full list rather than panicking. +func objectKey[T any](item *T) (key string, ok bool) { + obj, isObj := any(item).(metav1.Object) + if !isObj { + return "", false + } + + return obj.GetNamespace() + keySeparator + obj.GetName(), true +} + +// resumeIndex returns the index of the first item whose key sorts strictly +// after lastKey. Items are already sorted by key, so a binary search finds the +// boundary; a since-deleted lastKey simply resumes at the next greater key +// rather than erroring. +func resumeIndex[T any](items []T, lastKey string) int { + return sort.Search(len(items), func(i int) bool { + key, ok := objectKey(&items[i]) + + return !ok || key > lastKey + }) +} + +// listPage slices items for a `?limit=&continue=` list request (client-go's +// chunked pager / kubectl chunked listing). When limit is absent or non-positive +// the full slice is returned with an empty token, preserving the unpaginated +// default. The returned string is the value for list metadata.continue — "" on +// the final (or only) page. Items MUST already be sorted by key; every list path +// here sorts by namespace/name before calling this. +// +// Resume is anchored to the token's last-emitted key: the next page skips to the +// first item whose key is strictly greater, so a mutation before that key cannot +// skip or duplicate a later item. A malformed continue token (bad base64/JSON or +// wrong shape) writes a 410 Gone Status and returns ok=false — the client-go +// contract — instead of silently returning the full list. A well-formed token +// whose key was since deleted is NOT an error: resume proceeds from the next +// greater key. +func listPage[T any](items []T, w http.ResponseWriter, r *http.Request) (page []T, cont string, ok bool) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + return items, "", true + } + + // Elements that aren't metav1.Object can't be key-paginated — return the + // full list. All elements share type T, so probing the first suffices. + if len(items) > 0 { + if _, keyed := objectKey(&items[0]); !keyed { + return items, "", true + } + } + + start := 0 + + if raw := r.URL.Query().Get("continue"); raw != "" { + tok, err := decodePageToken(raw) + if err != nil { + writeStatus(w, http.StatusGone, metav1.StatusReasonExpired, + "k8s api: continue token is expired or malformed: "+err.Error()) + + return nil, "", false + } + + start = resumeIndex(items, tok.LastKey) + } + + end := start + limit + if end >= len(items) { + return items[start:], "", true + } + + nextKey, _ := objectKey(&items[end-1]) + + return items[start:end], encodePageToken(nextKey), true +} diff --git a/services/kubernetes/pagination_test.go b/services/kubernetes/pagination_test.go new file mode 100644 index 00000000..da601922 --- /dev/null +++ b/services/kubernetes/pagination_test.go @@ -0,0 +1,234 @@ +// Tests for the k8s chunked-list pager (PR #314, Finding 6): key-anchored +// continue tokens (no skip/duplicate under concurrent mutation) and a 410 Gone +// on a malformed token, replacing the old integer-offset behavior. + +package kubernetes_test + +import ( + "fmt" + "net/http" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// seedConfigMaps creates n config maps named cm-00, cm-01, … in the default +// namespace so their sort key (default/cm-NN) matches numeric order. +func seedConfigMaps(t *testing.T, base string, n int) { + t.Helper() + + for i := 0; i < n; i++ { + createConfigMap(t, base, fmt.Sprintf("cm-%02d", i)) + } +} + +func createConfigMap(t *testing.T, base, name string) { + t.Helper() + + cm := mustJSON(t, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Data: map[string]string{"k": "v"}, + }) + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/configmaps", cm) + resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create configmap %s: got %d, want 201", name, resp.StatusCode) + } +} + +func deleteConfigMap(t *testing.T, base, name string) { + t.Helper() + + resp := do(t, http.MethodDelete, base+"/api/v1/namespaces/default/configmaps/"+name, nil) + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("delete configmap %s: got %d, want 200", name, resp.StatusCode) + } +} + +// listConfigMapPage fetches one page and returns the item names plus the +// continue token. cont is the raw ?continue= value ("" for the first page). +func listConfigMapPage(t *testing.T, base string, limit int, cont string) ([]string, string) { + t.Helper() + + url := fmt.Sprintf("%s/api/v1/namespaces/default/configmaps?limit=%d", base, limit) + if cont != "" { + url += "&continue=" + cont + } + + resp := do(t, http.MethodGet, url, nil) + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + t.Fatalf("list page (continue=%q): got %d, want 200", cont, resp.StatusCode) + } + + var list corev1.ConfigMapList + mustDecode(t, resp.Body, &list) + + names := make([]string, 0, len(list.Items)) + for i := range list.Items { + names = append(names, list.Items[i].Name) + } + + return names, list.Continue +} + +func contains(names []string, want string) bool { + for _, n := range names { + if n == want { + return true + } + } + + return false +} + +// TestPager_NoSkipOnDeleteBeforeBoundary is the core Finding-6 regression: with +// the old integer offset, deleting an item before the page boundary shifted the +// offset and skipped the item that slid into that slot. Key-anchored resume +// makes page 2 start strictly after the boundary key regardless. +func TestPager_NoSkipOnDeleteBeforeBoundary(t *testing.T) { + base, done := newFixture(t) + defer done() + + seedConfigMaps(t, base, 10) // cm-00 .. cm-09 + + page1, cont := listConfigMapPage(t, base, 3, "") + if want := []string{"cm-00", "cm-01", "cm-02"}; !equalStrings(page1, want) { + t.Fatalf("page1: got %v, want %v", page1, want) + } + + if cont == "" { + t.Fatal("page1: expected a non-empty continue token") + } + + // Delete cm-01, which sorts BEFORE the boundary key cm-02. + deleteConfigMap(t, base, "cm-01") + + page2, _ := listConfigMapPage(t, base, 3, cont) + + // Resume is anchored to cm-02, so page2 begins at cm-03 — no skip of cm-03 + // (the old offset bug) and no duplicate of cm-02. + if want := []string{"cm-03", "cm-04", "cm-05"}; !equalStrings(page2, want) { + t.Fatalf("page2 after delete-before-boundary: got %v, want %v", page2, want) + } + + if contains(page2, "cm-02") { + t.Fatal("page2 duplicated the boundary item cm-02") + } +} + +// TestPager_DeleteAfterBoundaryAbsent verifies an item deleted after the +// boundary is simply not served (correct), and an item inserted before the +// boundary does not perturb the page-2 window. +func TestPager_DeleteAfterBoundaryAbsent(t *testing.T) { + base, done := newFixture(t) + defer done() + + seedConfigMaps(t, base, 10) // cm-00 .. cm-09 + + _, cont := listConfigMapPage(t, base, 3, "") // boundary key = cm-02 + + // Delete cm-05 (after the boundary) and insert cm-015 (before it). + deleteConfigMap(t, base, "cm-05") + createConfigMap(t, base, "cm-015") + + page2, _ := listConfigMapPage(t, base, 3, cont) + + if contains(page2, "cm-05") { + t.Fatalf("page2 served the deleted-after-boundary item cm-05: %v", page2) + } + + if contains(page2, "cm-015") { + t.Fatalf("page2 window shifted to include the before-boundary insert cm-015: %v", page2) + } + + // cm-05 is gone, so the next three after cm-02 are cm-03, cm-04, cm-06. + if want := []string{"cm-03", "cm-04", "cm-06"}; !equalStrings(page2, want) { + t.Fatalf("page2: got %v, want %v", page2, want) + } +} + +// TestPager_MalformedTokenGone asserts a garbage continue value returns 410 Gone +// with a Status body (client-go's ResourceExpired contract), not a 200 full list. +func TestPager_MalformedTokenGone(t *testing.T) { + base, done := newFixture(t) + defer done() + + seedConfigMaps(t, base, 5) + + url := base + "/api/v1/namespaces/default/configmaps?limit=2&continue=@@not-base64@@" + resp := do(t, http.MethodGet, url, nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusGone { + t.Fatalf("malformed continue token: got %d, want 410", resp.StatusCode) + } + + var status metav1.Status + mustDecode(t, resp.Body, &status) + + if status.Kind != "Status" || status.Reason != metav1.StatusReasonExpired { + t.Fatalf("malformed token body: got kind=%q reason=%q, want Status/Expired", status.Kind, status.Reason) + } +} + +// TestPager_RoundTripCoversAllOnce pages through every object in chunks and +// asserts each appears exactly once and the final page has an empty continue. +func TestPager_RoundTripCoversAllOnce(t *testing.T) { + base, done := newFixture(t) + defer done() + + const total = 10 + + seedConfigMaps(t, base, total) + + seen := map[string]int{} + cont := "" + pages := 0 + + for { + names, next := listConfigMapPage(t, base, 3, cont) + for _, n := range names { + seen[n]++ + } + + pages++ + if pages > total+1 { + t.Fatal("pager did not terminate") + } + + if next == "" { + break + } + + cont = next + } + + if len(seen) != total { + t.Fatalf("distinct objects seen: got %d, want %d (%v)", len(seen), total, seen) + } + + for name, count := range seen { + if count != 1 { + t.Fatalf("object %s served %d times, want exactly 1", name, count) + } + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} diff --git a/services/kubernetes/pdb.go b/services/kubernetes/pdb.go index 0218f59a..d2e46091 100644 --- a/services/kubernetes/pdb.go +++ b/services/kubernetes/pdb.go @@ -35,7 +35,7 @@ func (s *ClusterState) servePDBs(w http.ResponseWriter, r *http.Request, route * return } - s.listPDBs(w, "") + s.listPDBs(w, r, "") return } @@ -58,7 +58,7 @@ func (s *ClusterState) servePDBs(w http.ResponseWriter, r *http.Request, route * func (s *ClusterState) servePDBCollection(w http.ResponseWriter, r *http.Request, route *Route) { switch r.Method { case http.MethodGet: - s.listPDBs(w, route.Namespace) + s.listPDBs(w, r, route.Namespace) case http.MethodPost: s.createPDB(w, r, route) default: @@ -73,7 +73,7 @@ func (s *ClusterState) servePDBItem(w http.ResponseWriter, r *http.Request, rout case http.MethodPut: s.replacePDB(w, r, route) case http.MethodDelete: - s.deletePDB(w, route) + s.deletePDB(w, r, route) default: writeMethodNotAllowed(w, "k8s api: poddisruptionbudget: method not allowed: "+r.Method) } @@ -104,7 +104,7 @@ func (s *ClusterState) createPDB(w http.ResponseWriter, r *http.Request, route * return } - stamp(&in.ObjectMeta) + s.stamp(&in.ObjectMeta) in.TypeMeta = metav1.TypeMeta{Kind: "PodDisruptionBudget", APIVersion: "policy/v1"} // Real PDB status is computed by the disruption controller from live pods. @@ -112,6 +112,12 @@ func (s *ClusterState) createPDB(w http.ResponseWriter, r *http.Request, route * // inventing eviction semantics the emulator cannot honor. in.Status = policyv1.PodDisruptionBudgetStatus{ObservedGeneration: 1} + if isDryRun(r) { + writeJSON(w, http.StatusCreated, &in) + + return + } + s.pdbs[pdbKey(in.Namespace, in.Name)] = &in writeJSON(w, http.StatusCreated, &in) @@ -156,12 +162,18 @@ func (s *ClusterState) replacePDB(w http.ResponseWriter, r *http.Request, route in.TypeMeta = metav1.TypeMeta{Kind: "PodDisruptionBudget", APIVersion: "policy/v1"} in.Status = existing.Status + if isDryRun(r) { + writeJSON(w, http.StatusOK, &in) + + return + } + s.pdbs[key] = &in writeJSON(w, http.StatusOK, &in) } -func (s *ClusterState) deletePDB(w http.ResponseWriter, route *Route) { +func (s *ClusterState) deletePDB(w http.ResponseWriter, r *http.Request, route *Route) { s.mu.Lock() defer s.mu.Unlock() @@ -172,13 +184,19 @@ func (s *ClusterState) deletePDB(w http.ResponseWriter, route *Route) { return } + if isDryRun(r) { + writeJSON(w, http.StatusOK, &metav1.Status{Status: metav1.StatusSuccess}) + + return + } + delete(s.pdbs, key) writeJSON(w, http.StatusOK, &metav1.Status{Status: metav1.StatusSuccess}) } // listPDBs lists one namespace, or every namespace when namespace is "". -func (s *ClusterState) listPDBs(w http.ResponseWriter, namespace string) { +func (s *ClusterState) listPDBs(w http.ResponseWriter, r *http.Request, namespace string) { s.mu.RLock() defer s.mu.RUnlock() @@ -198,8 +216,14 @@ func (s *ClusterState) listPDBs(w http.ResponseWriter, namespace string) { return items[i].Name < items[j].Name }) + items, cont, ok := listPage(items, w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &policyv1.PodDisruptionBudgetList{ TypeMeta: metav1.TypeMeta{APIVersion: "policy/v1", Kind: "PodDisruptionBudgetList"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } diff --git a/services/kubernetes/phase1_fidelity_test.go b/services/kubernetes/phase1_fidelity_test.go new file mode 100644 index 00000000..d185804f --- /dev/null +++ b/services/kubernetes/phase1_fidelity_test.go @@ -0,0 +1,290 @@ +// Tests for the Phase 1 surface-fidelity work: server-side dry-run, Event +// field selectors, pod log/exec subresources, the Job reconcile shrink fix, +// and pod-count-clamp surfacing. + +package kubernetes_test + +import ( + "io" + "net/http" + "strings" + "testing" +) + +// decodeMap decodes a JSON response body into a generic map. +func decodeMap(t *testing.T, body io.ReadCloser) map[string]any { + t.Helper() + + out := map[string]any{} + mustDecode(t, body, &out) + + return out +} + +func nestedInt(t *testing.T, m map[string]any, path ...string) (int64, bool) { + t.Helper() + + cur := any(m) + for _, p := range path { + asMap, ok := cur.(map[string]any) + if !ok { + return 0, false + } + + cur, ok = asMap[p] + if !ok { + return 0, false + } + } + + f, ok := cur.(float64) // encoding/json numbers decode to float64 + if !ok { + return 0, false + } + + return int64(f), true +} + +func TestDryRun_TypedCreateNotPersisted(t *testing.T) { + base, done := newFixture(t) + defer done() + + body := mustJSON(t, map[string]any{ + "apiVersion": "v1", "kind": "ConfigMap", + "metadata": map[string]any{"name": "dry"}, + "data": map[string]any{"k": "v"}, + }) + + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/configmaps?dryRun=All", body) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("dry-run create: got %d, want 201", resp.StatusCode) + } + resp.Body.Close() + + get := do(t, http.MethodGet, base+"/api/v1/namespaces/default/configmaps/dry", nil) + if get.StatusCode != http.StatusNotFound { + t.Fatalf("after dry-run, GET: got %d, want 404 (must not persist)", get.StatusCode) + } + get.Body.Close() +} + +func TestDryRun_RegistryCreateNotPersisted(t *testing.T) { + base, done := newFixture(t) + defer done() + + body := mustJSON(t, map[string]any{ + "apiVersion": "batch/v1", "kind": "Job", + "metadata": map[string]any{"name": "dryjob"}, + "spec": map[string]any{ + "template": map[string]any{"spec": map[string]any{ + "containers": []any{map[string]any{"name": "c", "image": "img"}}, + }}, + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/batch/v1/namespaces/default/jobs?dryRun=All", body) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("dry-run job create: got %d, want 201", resp.StatusCode) + } + resp.Body.Close() + + get := do(t, http.MethodGet, base+"/apis/batch/v1/namespaces/default/jobs/dryjob", nil) + if get.StatusCode != http.StatusNotFound { + t.Fatalf("after dry-run, GET job: got %d, want 404 (must not persist)", get.StatusCode) + } + get.Body.Close() + + // A dry-run Job must not have materialized any Pods either. + pods := do(t, http.MethodGet, base+"/api/v1/namespaces/default/pods", nil) + defer pods.Body.Close() + + list := decodeMap(t, pods.Body) + if items, ok := list["items"].([]any); ok && len(items) != 0 { + t.Fatalf("dry-run job left %d pods behind", len(items)) + } +} + +func TestEventFieldSelector(t *testing.T) { + base, done := newFixture(t) + defer done() + + mkEvent := func(name, involved, reason string) { + body := mustJSON(t, map[string]any{ + "apiVersion": "v1", + "kind": "Event", + "metadata": map[string]any{"name": name}, + "involvedObject": map[string]any{"kind": "Pod", "name": involved}, + "reason": reason, + "type": "Normal", + }) + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/events", body) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create event %s: got %d", name, resp.StatusCode) + } + resp.Body.Close() + } + + mkEvent("e1", "pod-a", "Started") + mkEvent("e2", "pod-b", "Killing") + + resp := do(t, http.MethodGet, + base+"/api/v1/namespaces/default/events?fieldSelector=involvedObject.name=pod-a", nil) + defer resp.Body.Close() + + list := decodeMap(t, resp.Body) + + items, ok := list["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("involvedObject.name=pod-a: got %d items, want 1", len(items)) + } + + // And a reason selector. + resp2 := do(t, http.MethodGet, + base+"/api/v1/namespaces/default/events?fieldSelector=reason=Killing", nil) + defer resp2.Body.Close() + + list2 := decodeMap(t, resp2.Body) + if items, ok := list2["items"].([]any); !ok || len(items) != 1 { + t.Fatalf("reason=Killing: got %d items, want 1", len(items)) + } +} + +func TestPodLog_Synthetic(t *testing.T) { + base, done := newFixture(t) + defer done() + + createPodNamed(t, base, "logpod") + + resp := do(t, http.MethodGet, base+"/api/v1/namespaces/default/pods/logpod/log", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("pod log: got %d, want 200", resp.StatusCode) + } + + out, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(out), "synthetic log") { + t.Fatalf("pod log body = %q, want synthetic marker", string(out)) + } +} + +func TestPodExec_TypedNotImplemented(t *testing.T) { + base, done := newFixture(t) + defer done() + + createPodNamed(t, base, "execpod") + + resp := do(t, http.MethodGet, base+"/api/v1/namespaces/default/pods/execpod/exec", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotImplemented { + t.Fatalf("pod exec: got %d, want 501", resp.StatusCode) + } + + status := decodeMap(t, resp.Body) + if status["kind"] != "Status" { + t.Fatalf("pod exec: want typed Status object, got kind=%v", status["kind"]) + } +} + +func TestJobReconcile_ShrinkCorrectsSucceeded(t *testing.T) { + base, done := newFixture(t) + defer done() + + job := func(completions int) []byte { + return mustJSON(t, map[string]any{ + "apiVersion": "batch/v1", "kind": "Job", + "metadata": map[string]any{"name": "j1"}, + "spec": map[string]any{ + "completions": completions, + "template": map[string]any{"spec": map[string]any{ + "containers": []any{map[string]any{"name": "c", "image": "img"}}, + }}, + }, + }) + } + + resp := do(t, http.MethodPost, base+"/apis/batch/v1/namespaces/default/jobs", job(3)) + created := decodeMap(t, resp.Body) + resp.Body.Close() + + if got, _ := nestedInt(t, created, "status", "succeeded"); got != 3 { + t.Fatalf("initial job status.succeeded: got %d, want 3", got) + } + + // Shrink completions to 1: succeeded must follow down, not stay at 3. + resp2 := do(t, http.MethodPut, base+"/apis/batch/v1/namespaces/default/jobs/j1", job(1)) + updated := decodeMap(t, resp2.Body) + resp2.Body.Close() + + if got, _ := nestedInt(t, updated, "status", "succeeded"); got != 1 { + t.Fatalf("after shrink, job status.succeeded: got %d, want 1 (overstated succeeded bug)", got) + } + + // Exactly one owned Pod should remain. + pods := do(t, http.MethodGet, base+"/api/v1/namespaces/default/pods", nil) + defer pods.Body.Close() + + list := decodeMap(t, pods.Body) + + items, _ := list["items"].([]any) + if len(items) != 1 { + t.Fatalf("after shrink, pod count: got %d, want 1", len(items)) + } +} + +func TestPodCountClamp_Annotation(t *testing.T) { + base, done := newFixture(t) + defer done() + + body := mustJSON(t, map[string]any{ + "apiVersion": "apps/v1", "kind": "ReplicaSet", + "metadata": map[string]any{"name": "big"}, + "spec": map[string]any{ + "replicas": 600, + "selector": map[string]any{"matchLabels": map[string]any{"app": "x"}}, + "template": map[string]any{ + "metadata": map[string]any{"labels": map[string]any{"app": "x"}}, + "spec": map[string]any{ + "containers": []any{map[string]any{"name": "c", "image": "i"}}, + }, + }, + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/apps/v1/namespaces/default/replicasets", body) + defer resp.Body.Close() + + obj := decodeMap(t, resp.Body) + + if got, _ := nestedInt(t, obj, "status", "replicas"); got != 500 { + t.Fatalf("clamped status.replicas: got %d, want 500", got) + } + + meta, _ := obj["metadata"].(map[string]any) + anns, _ := meta["annotations"].(map[string]any) + + if anns["cloudemu.io/pod-count-clamped"] == nil { + t.Fatalf("expected clamp annotation, got annotations=%v", anns) + } +} + +// createPodNamed POSTs a minimal Pod and fails the test on a non-201. +func createPodNamed(t *testing.T, base, name string) { + t.Helper() + + body := mustJSON(t, map[string]any{ + "apiVersion": "v1", "kind": "Pod", + "metadata": map[string]any{"name": name}, + "spec": map[string]any{ + "containers": []any{map[string]any{"name": "main", "image": "nginx"}}, + }, + }) + + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/pods", body) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create pod %s: got %d, want 201", name, resp.StatusCode) + } +} diff --git a/services/kubernetes/phase2_fidelity_test.go b/services/kubernetes/phase2_fidelity_test.go new file mode 100644 index 00000000..8ebbb812 --- /dev/null +++ b/services/kubernetes/phase2_fidelity_test.go @@ -0,0 +1,181 @@ +// Tests for the Phase 2 core-semantics work: deterministic clock, list +// pagination (limit/continue), and finalizer-gated deletion on the registry +// and typed paths. + +package kubernetes_test + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/services/kubernetes" +) + +// newFixtureWithClock is newFixture with a caller-supplied clock wired in +// before the cluster is registered, so every timestamp is deterministic. +func newFixtureWithClock(t *testing.T, clock config.Clock) (string, func()) { + t.Helper() + + api := kubernetes.NewAPIServer() + api.SetClock(clock) + uid, _ := api.RegisterCluster() + ts := httptest.NewServer(api) + api.SetBaseURL(ts.URL) + + return ts.URL + "/k8s/" + uid, ts.Close +} + +func TestDeterministicClock_CreationTimestamp(t *testing.T) { + fixed := time.Date(2021, 6, 15, 8, 30, 0, 0, time.UTC) + base, done := newFixtureWithClock(t, config.NewFakeClock(fixed)) + defer done() + + body := mustJSON(t, map[string]any{ + "apiVersion": "v1", "kind": "ConfigMap", + "metadata": map[string]any{"name": "stamped"}, + }) + + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/configmaps", body) + defer resp.Body.Close() + + obj := decodeMap(t, resp.Body) + meta, _ := obj["metadata"].(map[string]any) + + if got := meta["creationTimestamp"]; got != "2021-06-15T08:30:00Z" { + t.Fatalf("creationTimestamp = %v, want deterministic 2021-06-15T08:30:00Z", got) + } +} + +func TestListPagination_LimitAndContinue(t *testing.T) { + base, done := newFixture(t) + defer done() + + for _, n := range []string{"a", "b", "c"} { + body := mustJSON(t, map[string]any{ + "apiVersion": "v1", "kind": "ConfigMap", + "metadata": map[string]any{"name": "cm-" + n}, + }) + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/configmaps", body) + resp.Body.Close() + } + + // First page: limit=2 → 2 items + a continue token. + resp := do(t, http.MethodGet, base+"/api/v1/namespaces/default/configmaps?limit=2", nil) + page1 := decodeMap(t, resp.Body) + resp.Body.Close() + + items1, _ := page1["items"].([]any) + if len(items1) != 2 { + t.Fatalf("page 1: got %d items, want 2", len(items1)) + } + + meta1, _ := page1["metadata"].(map[string]any) + + cont, _ := meta1["continue"].(string) + if cont == "" { + t.Fatalf("page 1: expected a continue token, got none") + } + + // Second page: resume → the remaining item, no further continue. + resp2 := do(t, http.MethodGet, base+"/api/v1/namespaces/default/configmaps?limit=2&continue="+cont, nil) + page2 := decodeMap(t, resp2.Body) + resp2.Body.Close() + + items2, _ := page2["items"].([]any) + if len(items2) != 1 { + t.Fatalf("page 2: got %d items, want 1", len(items2)) + } + + meta2, _ := page2["metadata"].(map[string]any) + if c, _ := meta2["continue"].(string); c != "" { + t.Fatalf("page 2: expected no further continue token, got %q", c) + } +} + +func TestFinalizers_RegistryKindGatedDeletion(t *testing.T) { + base, done := newFixture(t) + defer done() + + npURL := base + "/apis/networking.k8s.io/v1/namespaces/default/networkpolicies" + + create := mustJSON(t, map[string]any{ + "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": map[string]any{"name": "np", "finalizers": []any{"example.com/protect"}}, + "spec": map[string]any{"podSelector": map[string]any{}}, + }) + resp := do(t, http.MethodPost, npURL, create) + resp.Body.Close() + + // DELETE with a finalizer present → object goes Terminating, not removed. + del := do(t, http.MethodDelete, npURL+"/np", nil) + delObj := decodeMap(t, del.Body) + del.Body.Close() + + meta, _ := delObj["metadata"].(map[string]any) + if meta["deletionTimestamp"] == nil { + t.Fatalf("delete with finalizer: expected deletionTimestamp to be set") + } + + // Still retrievable. + get := do(t, http.MethodGet, npURL+"/np", nil) + if get.StatusCode != http.StatusOK { + t.Fatalf("after finalizer delete, GET: got %d, want 200 (still Terminating)", get.StatusCode) + } + get.Body.Close() + + // Removing the last finalizer completes the delete. + patch := mustJSON(t, map[string]any{"metadata": map[string]any{"finalizers": []any{}}}) + pr := do(t, http.MethodPatch, npURL+"/np", patch) + pr.Body.Close() + + gone := do(t, http.MethodGet, npURL+"/np", nil) + if gone.StatusCode != http.StatusNotFound { + t.Fatalf("after finalizer removed, GET: got %d, want 404", gone.StatusCode) + } + gone.Body.Close() +} + +func TestFinalizers_TypedPodGatedDeletion(t *testing.T) { + base, done := newFixture(t) + defer done() + + podURL := base + "/api/v1/namespaces/default/pods" + + create := mustJSON(t, map[string]any{ + "apiVersion": "v1", "kind": "Pod", + "metadata": map[string]any{"name": "fpod", "finalizers": []any{"example.com/protect"}}, + "spec": map[string]any{ + "containers": []any{map[string]any{"name": "c", "image": "nginx"}}, + }, + }) + resp := do(t, http.MethodPost, podURL, create) + resp.Body.Close() + + del := do(t, http.MethodDelete, podURL+"/fpod", nil) + delObj := decodeMap(t, del.Body) + del.Body.Close() + + meta, _ := delObj["metadata"].(map[string]any) + if meta["deletionTimestamp"] == nil { + t.Fatalf("pod delete with finalizer: expected deletionTimestamp set (Terminating)") + } + + get := do(t, http.MethodGet, podURL+"/fpod", nil) + if get.StatusCode != http.StatusOK { + t.Fatalf("Terminating pod GET: got %d, want 200", get.StatusCode) + } + get.Body.Close() + + patch := mustJSON(t, map[string]any{"metadata": map[string]any{"finalizers": []any{}}}) + pr := do(t, http.MethodPatch, podURL+"/fpod", patch) + pr.Body.Close() + + gone := do(t, http.MethodGet, podURL+"/fpod", nil) + if gone.StatusCode != http.StatusNotFound { + t.Fatalf("after finalizer removed, pod GET: got %d, want 404", gone.StatusCode) + } + gone.Body.Close() +} diff --git a/services/kubernetes/phase2_watch_internal_test.go b/services/kubernetes/phase2_watch_internal_test.go new file mode 100644 index 00000000..373cbc6a --- /dev/null +++ b/services/kubernetes/phase2_watch_internal_test.go @@ -0,0 +1,76 @@ +// Internal tests for the Phase 2 watch resume + BOOKMARK behavior of +// streamWatch (unexported, so this is package-internal). + +package kubernetes + +import ( + "context" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestStreamWatch_ResumeSkipsInitialSnapshot(t *testing.T) { + b := newBroadcaster() + sub := b.subscribe("") + + rec := httptest.NewRecorder() + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + // resume=true → the two seed items must NOT be replayed as ADDED. + streamWatch[string](ctx, rec, sub, []string{"seed-1", "seed-2"}, nil, watchOpts{resume: true}) + + if body := rec.Body.String(); strings.Contains(body, "ADDED") { + t.Fatalf("resume watch replayed the snapshot: %s", body) + } +} + +func TestStreamWatch_EmitsBookmarkAfterSync(t *testing.T) { + b := newBroadcaster() + sub := b.subscribe("") + + rec := httptest.NewRecorder() + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + streamWatch[string](ctx, rec, sub, []string{"seed"}, nil, watchOpts{ + bookmarks: true, + bookmarkObj: map[string]any{"metadata": map[string]any{"resourceVersion": "42"}}, + }) + + body := rec.Body.String() + if !strings.Contains(body, `"type":"BOOKMARK"`) { + t.Fatalf("expected a BOOKMARK event, got: %s", body) + } + + if !strings.Contains(body, `"resourceVersion":"42"`) { + t.Fatalf("BOOKMARK missing resourceVersion: %s", body) + } +} + +func TestWatchResumeAndBookmarkParsing(t *testing.T) { + cases := []struct { + query string + wantResume bool + wantBM bool + }{ + {"", false, false}, + {"resourceVersion=0", false, false}, + {"resourceVersion=15", true, false}, + {"allowWatchBookmarks=true", false, true}, + {"resourceVersion=9&allowWatchBookmarks=true", true, true}, + } + + for _, tc := range cases { + r := httptest.NewRequest("GET", "/x?"+tc.query, nil) + if got := watchResume(r); got != tc.wantResume { + t.Errorf("watchResume(%q) = %v, want %v", tc.query, got, tc.wantResume) + } + + if got := watchBookmarksEnabled(r); got != tc.wantBM { + t.Errorf("watchBookmarksEnabled(%q) = %v, want %v", tc.query, got, tc.wantBM) + } + } +} diff --git a/services/kubernetes/phase3_controllers_test.go b/services/kubernetes/phase3_controllers_test.go new file mode 100644 index 00000000..a6854b13 --- /dev/null +++ b/services/kubernetes/phase3_controllers_test.go @@ -0,0 +1,108 @@ +// Tests for Phase 3 controller materialization: Deployment→ReplicaSet→Pod +// interposition and DaemonSet nodeSelector honoring. + +package kubernetes_test + +import ( + "net/http" + "testing" +) + +func TestDeployment_InterposesReplicaSet(t *testing.T) { + base, done := newFixture(t) + defer done() + + dep := mustJSON(t, map[string]any{ + "apiVersion": "apps/v1", "kind": "Deployment", + "metadata": map[string]any{"name": "web"}, + "spec": map[string]any{ + "replicas": int64(2), + "selector": map[string]any{"matchLabels": map[string]any{"app": "web"}}, + "template": map[string]any{ + "metadata": map[string]any{"labels": map[string]any{"app": "web"}}, + "spec": map[string]any{"containers": []any{map[string]any{"name": "c", "image": "nginx"}}}, + }, + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/apps/v1/namespaces/default/deployments", dep) + resp.Body.Close() + + // A ReplicaSet owned by the Deployment must now exist. + rs := do(t, http.MethodGet, base+"/apis/apps/v1/namespaces/default/replicasets", nil) + defer rs.Body.Close() + + list := decodeMap(t, rs.Body) + + items, _ := list["items"].([]any) + if len(items) != 1 { + t.Fatalf("expected 1 ReplicaSet interposed for the Deployment, got %d", len(items)) + } + + item, _ := items[0].(map[string]any) + meta, _ := item["metadata"].(map[string]any) + owners, _ := meta["ownerReferences"].([]any) + if len(owners) == 0 { + t.Fatalf("interposed ReplicaSet has no ownerReferences") + } + + owner, _ := owners[0].(map[string]any) + if owner["kind"] != "Deployment" { + t.Fatalf("ReplicaSet owner kind = %v, want Deployment", owner["kind"]) + } +} + +func TestDaemonSet_NodeSelectorSkipsNonMatchingNode(t *testing.T) { + base, done := newFixture(t) + defer done() + + // A nodeSelector the single synthetic node does not satisfy → 0 pods. + ds := mustJSON(t, map[string]any{ + "apiVersion": "apps/v1", "kind": "DaemonSet", + "metadata": map[string]any{"name": "agent"}, + "spec": map[string]any{ + "selector": map[string]any{"matchLabels": map[string]any{"app": "agent"}}, + "template": map[string]any{ + "metadata": map[string]any{"labels": map[string]any{"app": "agent"}}, + "spec": map[string]any{ + "nodeSelector": map[string]any{"disktype": "ssd"}, + "containers": []any{map[string]any{"name": "c", "image": "img"}}, + }, + }, + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/apps/v1/namespaces/default/daemonsets", ds) + obj := decodeMap(t, resp.Body) + resp.Body.Close() + + status, _ := obj["status"].(map[string]any) + if got, _ := nestedInt(t, status, "desiredNumberScheduled"); got != 0 { + t.Fatalf("non-matching nodeSelector: desiredNumberScheduled = %d, want 0", got) + } + + // A matching selector → 1 pod. + ds2 := mustJSON(t, map[string]any{ + "apiVersion": "apps/v1", "kind": "DaemonSet", + "metadata": map[string]any{"name": "agent2"}, + "spec": map[string]any{ + "selector": map[string]any{"matchLabels": map[string]any{"app": "a2"}}, + "template": map[string]any{ + "metadata": map[string]any{"labels": map[string]any{"app": "a2"}}, + "spec": map[string]any{ + "nodeSelector": map[string]any{"kubernetes.io/hostname": "cloudemu-node-0"}, + "containers": []any{map[string]any{"name": "c", "image": "img"}}, + }, + }, + }, + }) + + resp2 := do(t, http.MethodPost, base+"/apis/apps/v1/namespaces/default/daemonsets", ds2) + obj2 := decodeMap(t, resp2.Body) + resp2.Body.Close() + + status2, _ := obj2["status"].(map[string]any) + if got, _ := nestedInt(t, status2, "desiredNumberScheduled"); got != 1 { + t.Fatalf("matching nodeSelector: desiredNumberScheduled = %d, want 1", got) + } +} diff --git a/services/kubernetes/phase4_crd_test.go b/services/kubernetes/phase4_crd_test.go new file mode 100644 index 00000000..1edfb64e --- /dev/null +++ b/services/kubernetes/phase4_crd_test.go @@ -0,0 +1,124 @@ +// Tests for Phase 4: CustomResourceDefinition support — a created CRD +// dynamically registers a servable custom-resource kind, surfaces in discovery, +// and deregisters (cascade-deleting its CRs) when the CRD is deleted. + +package kubernetes_test + +import ( + "net/http" + "strings" + "testing" +) + +func createWidgetCRD(t *testing.T, base string) { + t.Helper() + + crd := mustJSON(t, map[string]any{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": map[string]any{"name": "widgets.example.com"}, + "spec": map[string]any{ + "group": "example.com", + "names": map[string]any{ + "plural": "widgets", "singular": "widget", "kind": "Widget", "listKind": "WidgetList", + }, + "scope": "Namespaced", + "versions": []any{ + map[string]any{ + "name": "v1", "served": true, "storage": true, + "subresources": map[string]any{"status": map[string]any{}}, + }, + }, + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/apiextensions.k8s.io/v1/customresourcedefinitions", crd) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create CRD: got %d, want 201", resp.StatusCode) + } + + obj := decodeMap(t, resp.Body) + status, _ := obj["status"].(map[string]any) + conds, _ := status["conditions"].([]any) + if len(conds) == 0 { + t.Fatalf("CRD status has no conditions (should be Established): %v", status) + } +} + +func TestCRD_DynamicKindServedAndDiscovered(t *testing.T) { + base, done := newFixture(t) + defer done() + + createWidgetCRD(t, base) + + // The custom resource kind is now servable via the generic handler. + widget := mustJSON(t, map[string]any{ + "apiVersion": "example.com/v1", "kind": "Widget", + "metadata": map[string]any{"name": "w1"}, + "spec": map[string]any{"size": int64(3)}, + }) + + cr := do(t, http.MethodPost, base+"/apis/example.com/v1/namespaces/default/widgets", widget) + if cr.StatusCode != http.StatusCreated { + cr.Body.Close() + t.Fatalf("create custom resource: got %d, want 201", cr.StatusCode) + } + cr.Body.Close() + + get := do(t, http.MethodGet, base+"/apis/example.com/v1/namespaces/default/widgets/w1", nil) + if get.StatusCode != http.StatusOK { + get.Body.Close() + t.Fatalf("get custom resource: got %d, want 200", get.StatusCode) + } + get.Body.Close() + + // Discovery advertises the new group-version + resource. + disc := do(t, http.MethodGet, base+"/apis/example.com/v1", nil) + body := decodeMap(t, disc.Body) + disc.Body.Close() + + resources, _ := body["resources"].([]any) + found := false + for _, r := range resources { + if rm, ok := r.(map[string]any); ok && rm["name"] == "widgets" { + found = true + } + } + + if !found { + t.Fatalf("discovery /apis/example.com/v1 does not advertise widgets: %v", resources) + } +} + +func TestCRD_DeleteDeregistersAndCascades(t *testing.T) { + base, done := newFixture(t) + defer done() + + createWidgetCRD(t, base) + + // Create a CR, then delete the CRD. + widget := mustJSON(t, map[string]any{ + "apiVersion": "example.com/v1", "kind": "Widget", + "metadata": map[string]any{"name": "w1"}, + }) + cr := do(t, http.MethodPost, base+"/apis/example.com/v1/namespaces/default/widgets", widget) + cr.Body.Close() + + del := do(t, http.MethodDelete, base+"/apis/apiextensions.k8s.io/v1/customresourcedefinitions/widgets.example.com", nil) + del.Body.Close() + + // The custom-resource kind is no longer served. + resp := do(t, http.MethodGet, base+"/apis/example.com/v1/namespaces/default/widgets/w1", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("after CRD delete, custom resource GET: got %d, want 404", resp.StatusCode) + } + + body := decodeMap(t, resp.Body) + if msg, _ := body["message"].(string); !strings.Contains(msg, "not") { + t.Logf("note: 404 message = %q", msg) + } +} diff --git a/services/kubernetes/phase5_ssa_test.go b/services/kubernetes/phase5_ssa_test.go new file mode 100644 index 00000000..71e67ba6 --- /dev/null +++ b/services/kubernetes/phase5_ssa_test.go @@ -0,0 +1,107 @@ +// Tests for Phase 5: server-side apply field ownership + conflict detection. + +package kubernetes_test + +import ( + "bytes" + "io" + "net/http" + "testing" +) + +// apply sends a server-side apply patch as fieldManager, returning the response. +func apply(t *testing.T, url, manager string, force bool, obj map[string]any) *http.Response { + t.Helper() + + body := mustJSON(t, obj) + u := url + "?fieldManager=" + manager + if force { + u += "&force=true" + } + + req, err := http.NewRequest(http.MethodPatch, u, bytes.NewReader(body)) + if err != nil { + t.Fatalf("new apply request: %v", err) + } + + req.Header.Set("Content-Type", "application/apply-patch+yaml") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("apply: %v", err) + } + + return resp +} + +func TestServerSideApply_OwnershipAndConflict(t *testing.T) { + base, done := newFixture(t) + defer done() + + // Seed a NetworkPolicy (a plain registry kind with a free-form spec). + npURL := base + "/apis/networking.k8s.io/v1/namespaces/default/networkpolicies" + seed := mustJSON(t, map[string]any{ + "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": map[string]any{"name": "np"}, + "spec": map[string]any{"podSelector": map[string]any{}}, + }) + c := do(t, http.MethodPost, npURL, seed) + c.Body.Close() + + // Manager "alice" applies a spec field and takes ownership. + itemURL := npURL + "/np" + a := apply(t, itemURL, "alice", false, map[string]any{ + "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": map[string]any{"name": "np"}, + "spec": map[string]any{"policyTypes": []any{"Ingress"}}, + }) + if a.StatusCode != http.StatusOK { + body, _ := io.ReadAll(a.Body) + a.Body.Close() + t.Fatalf("alice apply: got %d, want 200 (%s)", a.StatusCode, body) + } + + obj := decodeMap(t, a.Body) + a.Body.Close() + + meta, _ := obj["metadata"].(map[string]any) + if mf, _ := meta["managedFields"].([]any); len(mf) == 0 { + t.Fatalf("apply did not record managedFields") + } + + // Manager "bob" applying a DIFFERENT value to alice's field → 409 conflict. + b := apply(t, itemURL, "bob", false, map[string]any{ + "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": map[string]any{"name": "np"}, + "spec": map[string]any{"policyTypes": []any{"Egress"}}, + }) + if b.StatusCode != http.StatusConflict { + b.Body.Close() + t.Fatalf("bob conflicting apply: got %d, want 409", b.StatusCode) + } + b.Body.Close() + + // With force, bob wins. + f := apply(t, itemURL, "bob", true, map[string]any{ + "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": map[string]any{"name": "np"}, + "spec": map[string]any{"policyTypes": []any{"Egress"}}, + }) + if f.StatusCode != http.StatusOK { + f.Body.Close() + t.Fatalf("bob force apply: got %d, want 200", f.StatusCode) + } + f.Body.Close() + + // alice re-applying the SAME value she owns must NOT conflict (idempotent). + a2 := apply(t, itemURL, "alice", false, map[string]any{ + "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": map[string]any{"name": "np"}, + "spec": map[string]any{"podSelector": map[string]any{}}, + }) + if a2.StatusCode != http.StatusOK { + a2.Body.Close() + t.Fatalf("alice idempotent re-apply: got %d, want 200", a2.StatusCode) + } + a2.Body.Close() +} diff --git a/services/kubernetes/pod.go b/services/kubernetes/pod.go index 9b2c3a36..43f55445 100644 --- a/services/kubernetes/pod.go +++ b/services/kubernetes/pod.go @@ -1,6 +1,7 @@ package kubernetes import ( + "fmt" "net/http" "sort" "strings" @@ -15,7 +16,7 @@ import ( // Per-resource files share the dispatch shape on purpose; each resource keeps // its quirks (Service ClusterIP, Secret StringData merge) close to its type. // - +//nolint:dupl // per-resource dispatch shape; see comment above. func (s *ClusterState) servePods(w http.ResponseWriter, r *http.Request, route *Route) { if route.APIGroup != "" || route.APIVersion != apiVersionV1 { writeNotFound(w, "k8s api: pods are only served at /api/v1") @@ -89,7 +90,7 @@ func (s *ClusterState) servePodItem(w http.ResponseWriter, r *http.Request, name case http.MethodPatch: s.patchPod(w, r, namespace, name) case http.MethodDelete: - s.deletePod(w, namespace, name) + s.deletePod(w, r, namespace, name) default: writeMethodNotAllowed(w, "k8s api: pod item: method not allowed: "+r.Method) } @@ -120,9 +121,45 @@ func (s *ClusterState) createPod(w http.ResponseWriter, r *http.Request, namespa return } - stamp(&in.ObjectMeta) + // LimitRange defaulting/validation runs before dry-run so the echoed object + // reflects applied defaults. + if status := s.applyLimitRange(namespace, &in); status != nil { + writeJSON(w, int(status.Code), status) + + return + } + + s.stamp(&in.ObjectMeta) in.TypeMeta = metav1.TypeMeta{Kind: "Pod", APIVersion: "v1"} + // Admission webhooks (opt-in) validate/mutate before dry-run echoes or the + // object is persisted. + if handled := s.admit(w, opCreate, gvrPods(), &in); handled { + return + } + + if isDryRun(r) { + // A dry-run must still report the 403 a real create would when the + // namespace is at its Pod quota — check (without reserving) before echo. + if status := s.checkQuotaLocked(namespace, "Pod", resourcePods); status != nil { + writeJSON(w, int(status.Code), status) + + return + } + + writeJSON(w, http.StatusCreated, &in) + + return + } + + // Quota is checked AND reserved only on a real (non-dry-run) create, so a + // dry-run never consumes quota. + if status := s.checkAndReserveQuota(namespace, "Pod", resourcePods); status != nil { + writeJSON(w, int(status.Code), status) + + return + } + pod := in // cloudemu has no kubelet; a directly-created Pod is driven Running (with a // synthetic IP and ready containers) so it behaves like a scheduled Pod. A @@ -140,8 +177,15 @@ func (s *ClusterState) listPods(w http.ResponseWriter, r *http.Request, namespac defer s.mu.RUnlock() items := filterPods(s.collectPodsLocked(namespace), r) + + items, cont, ok := listPage(items, w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &corev1.PodList{ TypeMeta: metav1.TypeMeta{Kind: "PodList", APIVersion: "v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } @@ -151,8 +195,15 @@ func (s *ClusterState) listPodsAllNamespaces(w http.ResponseWriter, r *http.Requ defer s.mu.RUnlock() items := filterPods(s.collectPodsLocked(""), r) + + items, cont, ok := listPage(items, w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &corev1.PodList{ TypeMeta: metav1.TypeMeta{Kind: "PodList", APIVersion: "v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } @@ -266,6 +317,29 @@ func (s *ClusterState) updatePod(w http.ResponseWriter, r *http.Request, namespa in.CreationTimestamp = cur.CreationTimestamp in.ResourceVersion = bumpResourceVersion(cur.ResourceVersion) in.TypeMeta = cur.TypeMeta + // deletionTimestamp is server-owned — preserve it across a PUT. + in.DeletionTimestamp = cur.DeletionTimestamp + // A plain PUT takes/shares ownership: preserve prior managedFields and record + // an Update entry for this fieldManager covering the fields it set. + in.ManagedFields = upsertTypedUpdateEntry(cur.ManagedFields, updateFieldManager(r), + ownedLeaves(objectMap(&in)), apiVersionV1, s.now()) + + if isDryRun(r) { + writeJSON(w, http.StatusOK, &in) + + return + } + + // Last finalizer removed on a Terminating Pod → complete the delete. + if finalizersDrained(&in.ObjectMeta) { + delete(s.pods, key) + s.releaseQuotaLocked(namespace, "Pod", resourcePods) + s.resyncEndpointsForNamespaceLocked(namespace) + s.wPods.publish(EventDeleted, namespace, *in.DeepCopy()) + writeJSON(w, http.StatusOK, &in) + + return + } pod := in // A spec-only PUT (no status) must not drop the Pod out of Running — keep it @@ -302,6 +376,35 @@ func (s *ClusterState) patchPod(w http.ResponseWriter, r *http.Request, namespac } patched.ResourceVersion = bumpResourceVersion(cur.ResourceVersion) + // Server-owned metadata: a merge-patch nulling deletionTimestamp (RFC 7396) + // must not resurrect a Terminating Pod — carry it (and uid/creation) forward, + // mirroring updatePod. + patched.DeletionTimestamp = cur.DeletionTimestamp + patched.UID = cur.UID + patched.CreationTimestamp = cur.CreationTimestamp + // A patch takes/shares ownership: record an Update entry covering the fields it + // changed. (Pods have no apply-patch handler, so every patch takes this path.) + patched.ManagedFields = upsertTypedUpdateEntry(cur.ManagedFields, updateFieldManager(r), + changedLeaves(objectMap(cur), objectMap(patched)), apiVersionV1, s.now()) + + if isDryRun(r) { + writeJSON(w, http.StatusOK, patched) + + return + } + + // A patch removing the last finalizer from a Terminating Pod completes the + // delete (patch inherits cur's deletionTimestamp). + if finalizersDrained(&patched.ObjectMeta) { + delete(s.pods, key) + s.releaseQuotaLocked(namespace, "Pod", resourcePods) + s.resyncEndpointsForNamespaceLocked(namespace) + s.wPods.publish(EventDeleted, namespace, *patched.DeepCopy()) + writeJSON(w, http.StatusOK, patched) + + return + } + s.pods[key] = patched // A patch may have changed labels that match a Service selector. s.resyncEndpointsForNamespaceLocked(namespace) @@ -309,7 +412,7 @@ func (s *ClusterState) patchPod(w http.ResponseWriter, r *http.Request, namespac writeJSON(w, http.StatusOK, patched) } -func (s *ClusterState) deletePod(w http.ResponseWriter, namespace, name string) { +func (s *ClusterState) deletePod(w http.ResponseWriter, r *http.Request, namespace, name string) { key := podKey(namespace, name) s.mu.Lock() @@ -322,7 +425,25 @@ func (s *ClusterState) deletePod(w http.ResponseWriter, namespace, name string) return } + if isDryRun(r) { + writeJSON(w, http.StatusOK, pod.DeepCopy()) + + return + } + + // Finalizer-gated deletion: a Pod with finalizers goes Terminating and is + // removed only when the last finalizer is dropped via update/patch. + if s.markForDeletion(&pod.ObjectMeta) { + pod.ResourceVersion = bumpResourceVersion(pod.ResourceVersion) + s.wPods.publish(EventModified, namespace, *pod.DeepCopy()) + writeJSON(w, http.StatusOK, pod.DeepCopy()) + + return + } + delete(s.pods, key) + // A quota-counted Pod going away must drop status.used back to the live count. + s.releaseQuotaLocked(namespace, "Pod", resourcePods) // A Service may have been pointing at this Pod — refresh its endpoints. s.resyncEndpointsForNamespaceLocked(namespace) s.wPods.publish(EventDeleted, namespace, *pod.DeepCopy()) @@ -332,3 +453,76 @@ func (s *ClusterState) deletePod(w http.ResponseWriter, namespace, name string) func podKey(namespace, name string) string { return namespace + "/" + name } + +// servePodSubresource serves the pod subresources kubectl reaches for. `log` +// returns synthetic container output (there are no real containers, but a +// clean 200 with a deterministic line keeps `kubectl logs` and log-scraping +// clients working). `exec`/`attach`/`portforward` require a streaming protocol +// upgrade the emulator does not implement and return a typed 501 Status so +// client-go surfaces a clear error rather than a raw connection failure. +func (s *ClusterState) servePodSubresource(w http.ResponseWriter, r *http.Request, route *Route) { + s.mu.RLock() + pod, ok := s.pods[podKey(route.Namespace, route.Name)] + + var container string + if ok { + container = firstContainerName(pod) + } + s.mu.RUnlock() + + if !ok { + writeNotFound(w, "k8s api: pod not found: "+podKey(route.Namespace, route.Name)) + + return + } + + switch route.Subresource { + case subresourcePodLog: + servePodLog(w, r, route, container) + case subresourcePodExec, subresourcePodAttach, subresourcePodPortForward: + writeStreamingUnsupported(w, route) + case subresourceEviction: + s.evictPod(w, r, route.Namespace, route.Name) + default: + writeNotFound(w, "k8s api: subresource not implemented: pods/"+route.Name+"/"+route.Subresource) + } +} + +// servePodLog writes a deterministic synthetic log line for the requested +// container. Streaming query params (follow, tail, previous) are accepted and +// ignored — the response is a single flush, which kubectl handles fine. +func servePodLog(w http.ResponseWriter, r *http.Request, route *Route, defaultContainer string) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, "k8s api: pods/log: method not allowed: "+r.Method) + + return + } + + container := r.URL.Query().Get("container") + if container == "" { + container = defaultContainer + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + // Response is text/plain (not HTML) and the identifiers are path-derived, so + // reflecting them carries no XSS risk. + //nolint:gosec // G705: text/plain log echo, not an HTML sink. + _, _ = fmt.Fprintf(w, "cloudemu: synthetic log stream for pod %s/%s container %q\n", + route.Namespace, route.Name, container) +} + +// writeStreamingUnsupported returns a typed 501 for the pod subresources that +// need a SPDY/WebSocket upgrade cloudemu does not implement. +func writeStreamingUnsupported(w http.ResponseWriter, route *Route) { + writeStatus(w, http.StatusNotImplemented, metav1.StatusReason("NotImplemented"), + "k8s api: pods/"+route.Subresource+" requires a streaming connection upgrade cloudemu does not implement") +} + +func firstContainerName(pod *corev1.Pod) string { + if len(pod.Spec.Containers) > 0 { + return pod.Spec.Containers[0].Name + } + + return "" +} diff --git a/services/kubernetes/pr314_fixes_test.go b/services/kubernetes/pr314_fixes_test.go new file mode 100644 index 00000000..ae9de7b9 --- /dev/null +++ b/services/kubernetes/pr314_fixes_test.go @@ -0,0 +1,358 @@ +// Regression tests for PR #314 data-plane fixes: CRD finalizer teardown, +// finalizer-aware owner GC / namespace cascade, patch-resurrection guards, +// ResourceQuota status.used accounting on delete, and dry-run quota enforcement. + +package kubernetes_test + +import ( + "net/http" + "testing" +) + +// nestedMap walks a decoded JSON object down a path of string keys, returning +// the map at the end (or nil if any hop is missing / not an object). +func nestedMap(m map[string]any, path ...string) map[string]any { + cur := m + for _, k := range path { + next, _ := cur[k].(map[string]any) + if next == nil { + return nil + } + + cur = next + } + + return cur +} + +func createWidgetCRDWithFinalizer(t *testing.T, base string) { + t.Helper() + + crd := mustJSON(t, map[string]any{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": map[string]any{ + "name": "widgets.example.com", + "finalizers": []any{"example.com/protect"}, + }, + "spec": map[string]any{ + "group": "example.com", + "names": map[string]any{ + "plural": "widgets", "singular": "widget", "kind": "Widget", "listKind": "WidgetList", + }, + "scope": "Namespaced", + "versions": []any{ + map[string]any{"name": "v1", "served": true, "storage": true}, + }, + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/apiextensions.k8s.io/v1/customresourcedefinitions", crd) + resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create CRD: got %d, want 201", resp.StatusCode) + } +} + +// Finding 1: a CRD deleted via the finalizer-drain path must still run onDelete, +// tearing down its custom-resource store + discovery entry — not just when the +// CRD is deleted immediately. +func TestCRD_FinalizerDrainDeregistersCRStore(t *testing.T) { + base, done := newFixture(t) + defer done() + + createWidgetCRDWithFinalizer(t, base) + + widget := mustJSON(t, map[string]any{ + "apiVersion": "example.com/v1", "kind": "Widget", + "metadata": map[string]any{"name": "w1"}, + }) + cr := do(t, http.MethodPost, base+"/apis/example.com/v1/namespaces/default/widgets", widget) + cr.Body.Close() + + if cr.StatusCode != http.StatusCreated { + t.Fatalf("create custom resource: got %d, want 201", cr.StatusCode) + } + + crdURL := base + "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/widgets.example.com" + + // DELETE with a finalizer → CRD goes Terminating, still established. + del := do(t, http.MethodDelete, crdURL, nil) + delObj := decodeMap(t, del.Body) + del.Body.Close() + + if nestedMap(delObj, "metadata")["deletionTimestamp"] == nil { + t.Fatalf("CRD delete with finalizer: expected deletionTimestamp set (Terminating)") + } + + // While Terminating, the CR store is still live. + stillServed := do(t, http.MethodGet, base+"/apis/example.com/v1/namespaces/default/widgets/w1", nil) + stillServed.Body.Close() + + if stillServed.StatusCode != http.StatusOK { + t.Fatalf("Terminating CRD: CR should still be served, got %d, want 200", stillServed.StatusCode) + } + + // Drain the finalizer → the delete completes and onDelete tears down the CR store. + patch := mustJSON(t, map[string]any{"metadata": map[string]any{"finalizers": []any{}}}) + pr := do(t, http.MethodPatch, crdURL, patch) + pr.Body.Close() + + // The custom-resource kind is no longer served (store deregistered). + gone := do(t, http.MethodGet, base+"/apis/example.com/v1/namespaces/default/widgets/w1", nil) + gone.Body.Close() + + if gone.StatusCode != http.StatusNotFound { + t.Fatalf("after CRD finalizer drain, CR GET: got %d, want 404 (store gone)", gone.StatusCode) + } +} + +// Finding 2: owner GC must honor a child's finalizers — a child carrying a +// finalizer goes Terminating rather than being hard-reaped, and only vanishes +// once its finalizers drain. A finalizer-free sibling is deleted immediately. +func TestOwnerGC_ChildFinalizerGoesTerminating(t *testing.T) { + base, done := newFixture(t) + defer done() + + npURL := base + "/apis/networking.k8s.io/v1/namespaces/default/networkpolicies" + np := mustJSON(t, map[string]any{ + "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": map[string]any{"name": "owner"}, + "spec": map[string]any{"podSelector": map[string]any{}}, + }) + npResp := do(t, http.MethodPost, npURL, np) + npObj := decodeMap(t, npResp.Body) + npResp.Body.Close() + + ownerUID, _ := nestedMap(npObj, "metadata")["uid"].(string) + if ownerUID == "" { + t.Fatal("owner NetworkPolicy has no uid") + } + + ownerRefs := []any{map[string]any{ + "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "name": "owner", "uid": ownerUID, "controller": true, + }} + + podURL := base + "/api/v1/namespaces/default/pods" + mkPod := func(name string, finalizers []any) []byte { + return mustJSON(t, map[string]any{ + "apiVersion": "v1", "kind": "Pod", + "metadata": map[string]any{ + "name": name, "ownerReferences": ownerRefs, "finalizers": finalizers, + }, + "spec": map[string]any{"containers": []any{map[string]any{"name": "c", "image": "nginx"}}}, + }) + } + + child := do(t, http.MethodPost, podURL, mkPod("child", []any{"example.com/protect"})) + child.Body.Close() + orphan := do(t, http.MethodPost, podURL, mkPod("orphan", nil)) + orphan.Body.Close() + + // Delete the owner → cascade GC. + del := do(t, http.MethodDelete, npURL+"/owner", nil) + del.Body.Close() + + // The finalizer-free child is hard-deleted. + og := do(t, http.MethodGet, podURL+"/orphan", nil) + og.Body.Close() + + if og.StatusCode != http.StatusNotFound { + t.Fatalf("finalizer-free child after GC: got %d, want 404", og.StatusCode) + } + + // The finalizer-bearing child is Terminating, not gone. + cg := do(t, http.MethodGet, podURL+"/child", nil) + cgObj := decodeMap(t, cg.Body) + cg.Body.Close() + + if cg.StatusCode != http.StatusOK { + t.Fatalf("finalizer child after GC: got %d, want 200 (Terminating)", cg.StatusCode) + } + + if nestedMap(cgObj, "metadata")["deletionTimestamp"] == nil { + t.Fatalf("finalizer child after GC: expected deletionTimestamp set") + } + + // Draining the finalizer completes the delete. + patch := mustJSON(t, map[string]any{"metadata": map[string]any{"finalizers": []any{}}}) + pr := do(t, http.MethodPatch, podURL+"/child", patch) + pr.Body.Close() + + gone := do(t, http.MethodGet, podURL+"/child", nil) + gone.Body.Close() + + if gone.StatusCode != http.StatusNotFound { + t.Fatalf("child after finalizer drain: got %d, want 404", gone.StatusCode) + } +} + +// Finding 3: a merge-patch nulling deletionTimestamp must not resurrect a +// Terminating object — the server-owned timestamp is restored after the patch. +func TestPatch_CannotResurrectTerminatingPod(t *testing.T) { + base, done := newFixture(t) + defer done() + + podURL := base + "/api/v1/namespaces/default/pods" + create := mustJSON(t, map[string]any{ + "apiVersion": "v1", "kind": "Pod", + "metadata": map[string]any{"name": "term", "finalizers": []any{"example.com/protect"}}, + "spec": map[string]any{"containers": []any{map[string]any{"name": "c", "image": "nginx"}}}, + }) + resp := do(t, http.MethodPost, podURL, create) + resp.Body.Close() + + del := do(t, http.MethodDelete, podURL+"/term", nil) + del.Body.Close() + + // RFC-7396 null-delete of the server-owned deletionTimestamp. + patch := mustJSON(t, map[string]any{"metadata": map[string]any{"deletionTimestamp": nil}}) + pr := do(t, http.MethodPatch, podURL+"/term", patch) + prObj := decodeMap(t, pr.Body) + pr.Body.Close() + + if nestedMap(prObj, "metadata")["deletionTimestamp"] == nil { + t.Fatalf("patch resurrected Terminating pod: deletionTimestamp was cleared") + } + + // Still present and still Terminating. + get := do(t, http.MethodGet, podURL+"/term", nil) + getObj := decodeMap(t, get.Body) + get.Body.Close() + + if get.StatusCode != http.StatusOK || nestedMap(getObj, "metadata")["deletionTimestamp"] == nil { + t.Fatalf("after patch, pod GET: status %d, deletionTimestamp %v", + get.StatusCode, nestedMap(getObj, "metadata")["deletionTimestamp"]) + } +} + +// Finding 3 (registry path): same guard on the generic registry patch handler. +func TestPatch_CannotResurrectTerminatingRegistryObject(t *testing.T) { + base, done := newFixture(t) + defer done() + + npURL := base + "/apis/networking.k8s.io/v1/namespaces/default/networkpolicies" + create := mustJSON(t, map[string]any{ + "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": map[string]any{"name": "term", "finalizers": []any{"example.com/protect"}}, + "spec": map[string]any{"podSelector": map[string]any{}}, + }) + resp := do(t, http.MethodPost, npURL, create) + resp.Body.Close() + + del := do(t, http.MethodDelete, npURL+"/term", nil) + del.Body.Close() + + patch := mustJSON(t, map[string]any{"metadata": map[string]any{"deletionTimestamp": nil}}) + pr := do(t, http.MethodPatch, npURL+"/term", patch) + prObj := decodeMap(t, pr.Body) + pr.Body.Close() + + if nestedMap(prObj, "metadata")["deletionTimestamp"] == nil { + t.Fatalf("patch resurrected Terminating NetworkPolicy: deletionTimestamp was cleared") + } + + get := do(t, http.MethodGet, npURL+"/term", nil) + get.Body.Close() + + if get.StatusCode != http.StatusOK { + t.Fatalf("after patch, NetworkPolicy GET: got %d, want 200 (still Terminating)", get.StatusCode) + } +} + +// Finding 4: ResourceQuota status.used must track the live object count on +// delete, not climb monotonically. +func TestQuota_UsedTracksLiveCountOnDelete(t *testing.T) { + base, done := newFixture(t) + defer done() + + quota := mustJSON(t, map[string]any{ + "apiVersion": "v1", "kind": "ResourceQuota", + "metadata": map[string]any{"name": "pod-quota"}, + "spec": map[string]any{"hard": map[string]any{"pods": "5"}}, + }) + qr := do(t, http.MethodPost, base+"/api/v1/namespaces/default/resourcequotas", quota) + qr.Body.Close() + + podURL := base + "/api/v1/namespaces/default/pods" + for _, name := range []string{"p1", "p2", "p3"} { + pod := mustJSON(t, map[string]any{ + "apiVersion": "v1", "kind": "Pod", + "metadata": map[string]any{"name": name}, + "spec": map[string]any{"containers": []any{map[string]any{"name": "c", "image": "nginx"}}}, + }) + resp := do(t, http.MethodPost, podURL, pod) + resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create pod %s: got %d, want 201", name, resp.StatusCode) + } + } + + quotaURL := base + "/api/v1/namespaces/default/resourcequotas/pod-quota" + + if got := quotaUsedPods(t, quotaURL); got != "3" { + t.Fatalf("status.used[pods] after 3 creates: got %q, want 3", got) + } + + dp := do(t, http.MethodDelete, podURL+"/p2", nil) + dp.Body.Close() + + if got := quotaUsedPods(t, quotaURL); got != "2" { + t.Fatalf("status.used[pods] after 1 delete: got %q, want 2 (must not be monotonic)", got) + } +} + +func quotaUsedPods(t *testing.T, quotaURL string) string { + t.Helper() + + resp := do(t, http.MethodGet, quotaURL, nil) + obj := decodeMap(t, resp.Body) + resp.Body.Close() + + used, _ := nestedMap(obj, "status", "used")["pods"].(string) + + return used +} + +// Finding 5: a server-side dry-run create against an at-limit namespace must +// report the same 403 a real create would, not a false success. +func TestQuota_DryRunReportsForbiddenAtLimit(t *testing.T) { + base, done := newFixture(t) + defer done() + + quota := mustJSON(t, map[string]any{ + "apiVersion": "v1", "kind": "ResourceQuota", + "metadata": map[string]any{"name": "pod-quota"}, + "spec": map[string]any{"hard": map[string]any{"pods": "1"}}, + }) + qr := do(t, http.MethodPost, base+"/api/v1/namespaces/default/resourcequotas", quota) + qr.Body.Close() + + podURL := base + "/api/v1/namespaces/default/pods" + first := mustJSON(t, map[string]any{ + "apiVersion": "v1", "kind": "Pod", + "metadata": map[string]any{"name": "p1"}, + "spec": map[string]any{"containers": []any{map[string]any{"name": "c", "image": "nginx"}}}, + }) + fr := do(t, http.MethodPost, podURL, first) + fr.Body.Close() + + if fr.StatusCode != http.StatusCreated { + t.Fatalf("create first pod: got %d, want 201", fr.StatusCode) + } + + second := mustJSON(t, map[string]any{ + "apiVersion": "v1", "kind": "Pod", + "metadata": map[string]any{"name": "p2"}, + "spec": map[string]any{"containers": []any{map[string]any{"name": "c", "image": "nginx"}}}, + }) + dr := do(t, http.MethodPost, podURL+"?dryRun=All", second) + dr.Body.Close() + + if dr.StatusCode != http.StatusForbidden { + t.Fatalf("dry-run create at quota limit: got %d, want 403", dr.StatusCode) + } +} diff --git a/services/kubernetes/quota.go b/services/kubernetes/quota.go new file mode 100644 index 00000000..cef848d7 --- /dev/null +++ b/services/kubernetes/quota.go @@ -0,0 +1,235 @@ +package kubernetes + +import ( + "fmt" + "net/http" + "strconv" + + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// quotaCountPrefix is the "count/" hard-limit key prefix real ResourceQuota +// objects use for arbitrary (non-legacy) resource kinds, e.g. +// "count/deployments.apps" or "count/replicasets.apps". +const quotaCountPrefix = "count/" + +// legacyQuotaResources are the core kinds real Kubernetes lets a quota's hard +// map reference by bare plural (no "count/" prefix, no group suffix) — the +// pre-generic-quota resource set upstream never migrated onto the count/ +// syntax for backward compatibility. +// +//nolint:gochecknoglobals // fixed protocol lookup set, not mutable state. +var legacyQuotaResources = map[string]bool{ + resourcePods: true, "configmaps": true, "secrets": true, "services": true, + "replicationcontrollers": true, "resourcequotas": true, "persistentvolumeclaims": true, +} + +// checkAndReserveQuota enforces every namespace-scoped ResourceQuota's object +// count against the kind being created. It returns a non-nil Status (403 +// Forbidden) when persisting the new object would push any matching quota's +// used count to or past its hard limit; the caller must abandon the create +// and return the Status. +// +// On success (nil return), every ResourceQuota that tracks this resource has +// its status.used bumped to reflect the object about to be persisted, so the +// reservation is atomic with the check under the caller's held s.mu.Lock — +// this method must only be called with that lock already held. +func (s *ClusterState) checkAndReserveQuota(namespace, kind, resourcePlural string) *metav1.Status { + store := s.reg.stores[regKey("", "v1", "resourcequotas")] + if store == nil { + return nil + } + + count, group := s.quotaTargetCountLocked(namespace, kind, resourcePlural) + keys := quotaHardKeys(resourcePlural, group) + + matches := matchingQuotas(store, namespace, keys) + + if status := quotaEnforce(matches, count, resourcePlural, group); status != nil { + return status + } + + for _, m := range matches { + bumpQuotaUsedLocked(store, m.obj, m.key, count+1) + } + + return nil +} + +// checkQuotaLocked is the reservation-free variant used on the dry-run path: it +// returns the same 403 a real create would, WITHOUT mutating any quota's +// status.used. A --dry-run=server create against an at-limit namespace must +// report the 403 a real apply would, not a false success. Callers hold s.mu. +func (s *ClusterState) checkQuotaLocked(namespace, kind, resourcePlural string) *metav1.Status { + store := s.reg.stores[regKey("", "v1", "resourcequotas")] + if store == nil { + return nil + } + + count, group := s.quotaTargetCountLocked(namespace, kind, resourcePlural) + keys := quotaHardKeys(resourcePlural, group) + + return quotaEnforce(matchingQuotas(store, namespace, keys), count, resourcePlural, group) +} + +// quotaEnforce returns the 403 Status when creating one more object would meet +// or exceed any matching quota's hard limit, else nil. +func quotaEnforce(matches []quotaMatch, count int, resourcePlural, group string) *metav1.Status { + for _, m := range matches { + limit, err := resource.ParseQuantity(m.hardValue) + if err != nil { + continue + } + + if int64(count) >= limit.Value() { + return quotaExceededStatus(m.obj.GetName(), resourcePlural, group, int64(count), limit.Value()) + } + } + + return nil +} + +// releaseQuotaLocked recomputes every matching ResourceQuota's status.used for +// kind/resourcePlural from the live object count in namespace, after an object +// has been removed. Recompute-from-live (rather than decrement) keeps the count +// correct even when a cascade removed several objects at once. Callers hold +// s.mu and must call this AFTER the object(s) have left the store. +func (s *ClusterState) releaseQuotaLocked(namespace, kind, resourcePlural string) { + store := s.reg.stores[regKey("", "v1", "resourcequotas")] + if store == nil { + return + } + + count, group := s.quotaTargetCountLocked(namespace, kind, resourcePlural) + keys := quotaHardKeys(resourcePlural, group) + + for _, m := range matchingQuotas(store, namespace, keys) { + bumpQuotaUsedLocked(store, m.obj, m.key, count) + } +} + +// quotaMatch is one ResourceQuota object whose hard map references the +// resource being created, via hard key key with the raw configured value. +type quotaMatch struct { + obj *unstructured.Unstructured + key string + hardValue string +} + +// matchingQuotas finds every quota in namespace whose spec.hard sets one of +// keys, returning at most one match per quota object. +func matchingQuotas(store *registryStore, namespace string, keys []string) []quotaMatch { + var matches []quotaMatch + + for _, obj := range store.items { + if obj.GetNamespace() != namespace { + continue + } + + hard, _, err := unstructured.NestedStringMap(obj.Object, "spec", "hard") + if err != nil || hard == nil { + continue + } + + for _, k := range keys { + if v, ok := hard[k]; ok { + matches = append(matches, quotaMatch{obj: obj, key: k, hardValue: v}) + + break + } + } + } + + return matches +} + +// quotaHardKeys returns the hard-map keys a quota could use to reference +// resourcePlural: the legacy bare-plural alias (if this is one of the +// grandfathered core kinds) plus the generic "count/[.]" form. +func quotaHardKeys(resourcePlural, group string) []string { + keys := []string{quotaCountKey(resourcePlural, group)} + if legacyQuotaResources[resourcePlural] { + keys = append(keys, resourcePlural) + } + + return keys +} + +func quotaCountKey(resourcePlural, group string) string { + if group == "" { + return quotaCountPrefix + resourcePlural + } + + return quotaCountPrefix + resourcePlural + "." + group +} + +// quotaTargetCountLocked returns the number of existing objects of kind in +// namespace, plus the resource's API group ("" for core). Pods are typed and +// counted from s.pods; everything else is looked up in the registry by +// plural. Callers hold s.mu. +func (s *ClusterState) quotaTargetCountLocked(namespace, kind, resourcePlural string) (count int, group string) { + if kind == "Pod" { + for _, p := range s.pods { + if p.Namespace == namespace { + count++ + } + } + + return count, "" + } + + for _, st := range s.reg.stores { + if st.def.plural != resourcePlural { + continue + } + + for _, obj := range st.items { + if obj.GetNamespace() == namespace { + count++ + } + } + + return count, st.def.group + } + + return 0, "" +} + +// quotaExceededStatus builds the 403 Forbidden Status a real apiserver +// returns when a create would exceed a ResourceQuota's hard object count. +func quotaExceededStatus(quotaName, resourcePlural, group string, used, limit int64) *metav1.Status { + key := quotaCountKey(resourcePlural, group) + msg := fmt.Sprintf("exceeded quota: %s, requested: %s=1, used: %s=%d, limited: %s=%d", + quotaName, key, key, used, key, limit) + + return &metav1.Status{ + TypeMeta: metav1.TypeMeta{Kind: "Status", APIVersion: "v1"}, + Status: metav1.StatusFailure, + Code: http.StatusForbidden, + Reason: metav1.StatusReasonForbidden, + Message: msg, + } +} + +// bumpQuotaUsedLocked records newUsed under hardKey in the quota's +// status.used (mirroring spec.hard into status.hard, as a real quota +// controller does) and publishes the change to the resourcequotas watch. +// Callers hold s.mu. +func bumpQuotaUsedLocked(store *registryStore, obj *unstructured.Unstructured, hardKey string, newUsed int) { + if hard, _, err := unstructured.NestedStringMap(obj.Object, "spec", "hard"); err == nil && hard != nil { + _ = unstructured.SetNestedStringMap(obj.Object, hard, "status", "hard") + } + + used, _, err := unstructured.NestedStringMap(obj.Object, "status", "used") + if err != nil || used == nil { + used = map[string]string{} + } + + used[hardKey] = strconv.Itoa(newUsed) + _ = unstructured.SetNestedStringMap(obj.Object, used, "status", "used") + + store.stampRVLocked(obj) + store.watch.publish(EventModified, obj.GetNamespace(), *obj.DeepCopy()) +} diff --git a/services/kubernetes/quota_test.go b/services/kubernetes/quota_test.go new file mode 100644 index 00000000..6d0a4b4a --- /dev/null +++ b/services/kubernetes/quota_test.go @@ -0,0 +1,93 @@ +package kubernetes_test + +import ( + "net/http" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestResourceQuota_ObjectCountEnforced pins the object-count enforcement a +// real apiserver's quota admission plugin performs: a ResourceQuota capping +// "pods" at 1 lets the first Pod through and rejects the second with 403. +func TestResourceQuota_ObjectCountEnforced(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + quota := &corev1.ResourceQuota{ + TypeMeta: metav1.TypeMeta{Kind: "ResourceQuota", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "pod-quota"}, + Spec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{"pods": resource.MustParse("1")}}, + } + + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/resourcequotas", mustJSON(t, quota)) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create quota: got %d, want 201", resp.StatusCode) + } + + resp.Body.Close() + + first := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "web-1"}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app", Image: "nginx:1.27"}}}, + } + + resp = do(t, http.MethodPost, base+"/api/v1/namespaces/default/pods", mustJSON(t, first)) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create first pod: got %d, want 201", resp.StatusCode) + } + + resp.Body.Close() + + second := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "web-2"}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app", Image: "nginx:1.27"}}}, + } + + resp = do(t, http.MethodPost, base+"/api/v1/namespaces/default/pods", mustJSON(t, second)) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("create second pod: got %d, want 403", resp.StatusCode) + } + + var status metav1.Status + mustDecode(t, resp.Body, &status) + + if status.Reason != metav1.StatusReasonForbidden { + t.Fatalf("status reason: got %q, want Forbidden", status.Reason) + } + + // The rejected create must not have been counted — the namespace still has + // exactly the one Pod the quota allowed. + resp = do(t, http.MethodGet, base+"/api/v1/namespaces/default/pods", nil) + + var list corev1.PodList + mustDecode(t, resp.Body, &list) + + if len(list.Items) != 1 { + t.Fatalf("pods after denied create: got %d, want 1", len(list.Items)) + } +} + +// TestResourceQuota_NoQuotaNoEnforcement pins that a namespace with no +// ResourceQuota object never gets denials — existing tests and callers that +// create many Pods in a namespace without a quota must keep working. +func TestResourceQuota_NoQuotaNoEnforcement(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + for i := range 3 { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "web-" + string(rune('a'+i))}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app", Image: "nginx:1.27"}}}, + } + + resp := do(t, http.MethodPost, base+"/api/v1/namespaces/default/pods", mustJSON(t, pod)) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create pod %d: got %d, want 201", i, resp.StatusCode) + } + + resp.Body.Close() + } +} diff --git a/services/kubernetes/rbac.go b/services/kubernetes/rbac.go new file mode 100644 index 00000000..43a4881e --- /dev/null +++ b/services/kubernetes/rbac.go @@ -0,0 +1,286 @@ +package kubernetes + +// rbac.go implements the authorization.k8s.io/v1 SubjectAccessReview API — a +// POST-only, non-persisted, cluster-scoped "review" resource. Real clusters +// use it (via `kubectl auth can-i` and client-go's SelfSubjectAccessReview +// helpers) to ask "would this request be allowed?" without actually issuing +// it. There's nothing to store: the request is evaluated against whatever +// Roles/ClusterRoles/RoleBindings/ClusterRoleBindings already exist in the +// registry and the answer is returned inline. +// +// Evaluation follows real RBAC semantics: a request is allowed if ANY +// binding that binds the reviewed subject (user, group, or service account) +// to a (Cluster)Role has a rule whose verb/apiGroup/resource (with "*" +// wildcards) — and, if the rule restricts resourceNames, the resource name — +// match the request. RBAC has no explicit deny, so the answer is a plain +// allow/no-opinion, not allow/deny. + +import ( + "net/http" + "slices" + + authorizationv1 "k8s.io/api/authorization/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// apiGroupAuthorization is the API group SubjectAccessReview is served +// under. It has no persisted store (registeredResources() doesn't list it), +// so it isn't a registry group — it's dispatched directly in ServeHTTP. +const apiGroupAuthorization = "authorization.k8s.io" + +// pathSubjectAccessReviews is the one path this file answers. +const pathSubjectAccessReviews = "/apis/authorization.k8s.io/v1/subjectaccessreviews" + +// wildcardAll is the RBAC "matches anything" sentinel for verbs, apiGroups, +// and resources (rbacv1.VerbAll / APIGroupAll / ResourceAll all equal "*"). +const wildcardAll = "*" + +// authorizationResources is the discovery entry for the review API — a +// create-only, non-namespaced virtual resource. Referenced from +// discovery.go's groupVersionDiscovery. +func authorizationResources() []apiResource { + return []apiResource{ + {"subjectaccessreviews", "subjectaccessreview", "SubjectAccessReview", false, []string{"create"}, nil}, + } +} + +// serveSubjectAccessReview decodes a SubjectAccessReview, evaluates it +// against the stored RBAC objects, and echoes it back with status.allowed +// filled in. +func (s *ClusterState) serveSubjectAccessReview(w http.ResponseWriter, r *http.Request) { + var sar authorizationv1.SubjectAccessReview + if !readJSON(w, r, &sar) { + return + } + + allowed, reason := s.checkAccess(&sar.Spec) + + sar.TypeMeta = metav1.TypeMeta{Kind: "SubjectAccessReview", APIVersion: apiGroupAuthorization + "/v1"} + sar.Status = authorizationv1.SubjectAccessReviewStatus{Allowed: allowed, Reason: reason} + + writeJSON(w, http.StatusCreated, &sar) +} + +// checkAccess evaluates a SubjectAccessReviewSpec's resourceAttributes +// against every RoleBinding in the request's namespace and every +// ClusterRoleBinding. A NonResourceAttributes-only review (no +// ResourceAttributes) has nothing RBAC-Role-shaped to match, so it's a +// no-opinion "not allowed" — the emulator has no non-resource-URL rules to +// evaluate. +func (s *ClusterState) checkAccess(spec *authorizationv1.SubjectAccessReviewSpec) (allowed bool, reason string) { + if spec.ResourceAttributes == nil { + return false, "no RBAC policy matched: nonResourceAttributes review is not evaluated" + } + + attrs := spec.ResourceAttributes + + s.mu.RLock() + defer s.mu.RUnlock() + + if attrs.Namespace != "" { + if allowed, reason := s.checkRoleBindingsLocked(attrs.Namespace, spec, attrs); allowed { + return true, reason + } + } + + if allowed, reason := s.checkClusterRoleBindingsLocked(spec, attrs); allowed { + return true, reason + } + + return false, "no RBAC policy matched" +} + +// checkRoleBindingsLocked evaluates the namespace-scoped RoleBindings bound +// to a Role or ClusterRole. Callers hold s.mu. +func (s *ClusterState) checkRoleBindingsLocked( + namespace string, spec *authorizationv1.SubjectAccessReviewSpec, attrs *authorizationv1.ResourceAttributes, +) (allowed bool, reason string) { + st := s.reg.stores[regKey(apiGroupRBAC, "v1", "rolebindings")] + if st == nil { + return false, "" + } + + for _, obj := range st.items { + if obj.GetNamespace() != namespace { + continue + } + + var rb rbacv1.RoleBinding + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &rb); err != nil { + continue + } + + if allowed, reason := s.evaluateBindingLocked(namespace, rb.Subjects, rb.RoleRef, spec, attrs); allowed { + return true, "allowed by RoleBinding " + namespace + "/" + rb.Name + ": " + reason + } + } + + return false, "" +} + +// checkClusterRoleBindingsLocked evaluates the cluster-scoped +// ClusterRoleBindings, which always bind to a ClusterRole and grant access +// regardless of the request's namespace. Callers hold s.mu. +func (s *ClusterState) checkClusterRoleBindingsLocked( + spec *authorizationv1.SubjectAccessReviewSpec, attrs *authorizationv1.ResourceAttributes, +) (allowed bool, reason string) { + st := s.reg.stores[regKey(apiGroupRBAC, "v1", "clusterrolebindings")] + if st == nil { + return false, "" + } + + for _, obj := range st.items { + var crb rbacv1.ClusterRoleBinding + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &crb); err != nil { + continue + } + + if allowed, reason := s.evaluateBindingLocked("", crb.Subjects, crb.RoleRef, spec, attrs); allowed { + return true, "allowed by ClusterRoleBinding " + crb.Name + ": " + reason + } + } + + return false, "" +} + +// evaluateBindingLocked reports whether a binding's subjects match the +// reviewed identity and, if so, whether the role it references grants the +// requested verb/group/resource/name. bindingNamespace is "" for a +// ClusterRoleBinding (cluster-scoped) or the RoleBinding's own namespace. +// Callers hold s.mu. +func (s *ClusterState) evaluateBindingLocked( + bindingNamespace string, subjects []rbacv1.Subject, ref rbacv1.RoleRef, + spec *authorizationv1.SubjectAccessReviewSpec, attrs *authorizationv1.ResourceAttributes, +) (allowed bool, reason string) { + matched := false + + for _, subj := range subjects { + if subjectMatches(subj, bindingNamespace, spec.User, spec.Groups) { + matched = true + + break + } + } + + if !matched { + return false, "" + } + + rules, ok := s.roleRulesLocked(bindingNamespace, ref) + if !ok { + return false, "" + } + + for _, rule := range rules { + if ruleMatches(&rule, attrs.Verb, attrs.Group, attrs.Resource, attrs.Name) { + return true, ref.Kind + " " + ref.Name + } + } + + return false, "" +} + +// roleRulesLocked resolves a RoleRef into its rules. A "Role" is looked up +// in bindingNamespace (RoleBindings can only reference a Role in their own +// namespace); a "ClusterRole" is cluster-scoped regardless of who +// references it. Callers hold s.mu. +func (s *ClusterState) roleRulesLocked(bindingNamespace string, ref rbacv1.RoleRef) ([]rbacv1.PolicyRule, bool) { + switch ref.Kind { + case "Role": + st := s.reg.stores[regKey(apiGroupRBAC, "v1", "roles")] + if st == nil { + return nil, false + } + + obj, ok := st.items[objKey(bindingNamespace, ref.Name)] + if !ok { + return nil, false + } + + var role rbacv1.Role + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &role); err != nil { + return nil, false + } + + return role.Rules, true + case "ClusterRole": + st := s.reg.stores[regKey(apiGroupRBAC, "v1", "clusterroles")] + if st == nil { + return nil, false + } + + obj, ok := st.items[objKey("", ref.Name)] + if !ok { + return nil, false + } + + var cr rbacv1.ClusterRole + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &cr); err != nil { + return nil, false + } + + return cr.Rules, true + default: + return nil, false + } +} + +// subjectMatches reports whether subj identifies the reviewed user/groups. +// A ServiceAccount subject matches the "system:serviceaccount::" +// convention client-go and kube-apiserver both use for SA identities; its +// namespace defaults to the binding's own namespace when unset (the shape +// every RoleBinding subject uses for a same-namespace ServiceAccount). +func subjectMatches(subj rbacv1.Subject, bindingNamespace, user string, groups []string) bool { + switch subj.Kind { + case rbacv1.UserKind: + return subj.Name == user + case rbacv1.GroupKind: + return slices.Contains(groups, subj.Name) + case rbacv1.ServiceAccountKind: + ns := subj.Namespace + if ns == "" { + ns = bindingNamespace + } + + return user == "system:serviceaccount:"+ns+":"+subj.Name + default: + return false + } +} + +// ruleMatches reports whether a PolicyRule authorizes verb/group/resource +// (with "*" wildcards), and — when the rule restricts resourceNames — that +// name is among them. +func ruleMatches(rule *rbacv1.PolicyRule, verb, group, resource, name string) bool { + if !matchesWildcard(rule.Verbs, verb) { + return false + } + + if !matchesWildcard(rule.APIGroups, group) { + return false + } + + if !matchesWildcard(rule.Resources, resource) { + return false + } + + if len(rule.ResourceNames) > 0 && !slices.Contains(rule.ResourceNames, name) { + return false + } + + return true +} + +// matchesWildcard reports whether val is in list, honoring RBAC's "*" +// wildcard entries. +func matchesWildcard(list []string, val string) bool { + for _, v := range list { + if v == wildcardAll || v == val { + return true + } + } + + return false +} diff --git a/services/kubernetes/rbac_test.go b/services/kubernetes/rbac_test.go new file mode 100644 index 00000000..98ee8928 --- /dev/null +++ b/services/kubernetes/rbac_test.go @@ -0,0 +1,224 @@ +package kubernetes_test + +import ( + "net/http" + "testing" +) + +// sarAllowed posts a SubjectAccessReview for user against verb/resource in +// namespace and returns status.allowed. +func sarAllowed(t *testing.T, base, user, verb, resource, namespace string) bool { + t.Helper() + + body := mustJSON(t, map[string]any{ + "apiVersion": "authorization.k8s.io/v1", + "kind": "SubjectAccessReview", + "spec": map[string]any{ + "user": user, + "resourceAttributes": map[string]any{ + "verb": verb, + "resource": resource, + "namespace": namespace, + }, + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/authorization.k8s.io/v1/subjectaccessreviews", body) + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("SubjectAccessReview: status %d", resp.StatusCode) + } + + m := decodeMap(t, resp.Body) + + status, _ := m["status"].(map[string]any) + allowed, _ := status["allowed"].(bool) + + return allowed +} + +func TestSubjectAccessReview_AllowAndDeny(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + roleBody := mustJSON(t, map[string]any{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "Role", + "metadata": map[string]any{"name": "pod-reader"}, + "rules": []map[string]any{ + {"apiGroups": []string{""}, "resources": []string{"pods"}, "verbs": []string{"get"}}, + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/rbac.authorization.k8s.io/v1/namespaces/default/roles", roleBody) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create Role: status %d", resp.StatusCode) + } + resp.Body.Close() + + bindingBody := mustJSON(t, map[string]any{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "RoleBinding", + "metadata": map[string]any{"name": "alice-pod-reader"}, + "subjects": []map[string]any{ + {"kind": "User", "name": "alice", "apiGroup": "rbac.authorization.k8s.io"}, + }, + "roleRef": map[string]any{ + "kind": "Role", "name": "pod-reader", "apiGroup": "rbac.authorization.k8s.io", + }, + }) + + resp = do(t, http.MethodPost, base+"/apis/rbac.authorization.k8s.io/v1/namespaces/default/rolebindings", bindingBody) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create RoleBinding: status %d", resp.StatusCode) + } + resp.Body.Close() + + if !sarAllowed(t, base, "alice", "get", "pods", "default") { + t.Error("alice: expected get pods to be allowed") + } + + if sarAllowed(t, base, "bob", "get", "pods", "default") { + t.Error("bob: expected get pods to be denied (no binding)") + } + + if sarAllowed(t, base, "alice", "delete", "pods", "default") { + t.Error("alice: expected delete pods to be denied (role only grants get)") + } +} + +func TestSubjectAccessReview_ClusterRoleBindingGroupSubject(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + crBody := mustJSON(t, map[string]any{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + "metadata": map[string]any{"name": "node-viewer"}, + "rules": []map[string]any{ + {"apiGroups": []string{""}, "resources": []string{"nodes"}, "verbs": []string{"list"}}, + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/rbac.authorization.k8s.io/v1/clusterroles", crBody) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create ClusterRole: status %d", resp.StatusCode) + } + resp.Body.Close() + + crbBody := mustJSON(t, map[string]any{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRoleBinding", + "metadata": map[string]any{"name": "admins-node-viewer"}, + "subjects": []map[string]any{ + {"kind": "Group", "name": "admins", "apiGroup": "rbac.authorization.k8s.io"}, + }, + "roleRef": map[string]any{ + "kind": "ClusterRole", "name": "node-viewer", "apiGroup": "rbac.authorization.k8s.io", + }, + }) + + resp = do(t, http.MethodPost, base+"/apis/rbac.authorization.k8s.io/v1/clusterrolebindings", crbBody) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create ClusterRoleBinding: status %d", resp.StatusCode) + } + resp.Body.Close() + + sarBody := mustJSON(t, map[string]any{ + "apiVersion": "authorization.k8s.io/v1", + "kind": "SubjectAccessReview", + "spec": map[string]any{ + "user": "carol", + "groups": []string{"admins"}, + "resourceAttributes": map[string]any{ + "verb": "list", "resource": "nodes", + }, + }, + }) + + resp = do(t, http.MethodPost, base+"/apis/authorization.k8s.io/v1/subjectaccessreviews", sarBody) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("SubjectAccessReview: status %d", resp.StatusCode) + } + + m := decodeMap(t, resp.Body) + status, _ := m["status"].(map[string]any) + + if allowed, _ := status["allowed"].(bool); !allowed { + t.Error("carol (group admins): expected list nodes to be allowed via ClusterRoleBinding") + } +} + +func TestSubjectAccessReview_ServiceAccountSubjectAndMissingRole(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + // RoleBinding referencing a Role that was never created: the binding's + // subject matches, but there's nothing to resolve rules from. + rbBody := mustJSON(t, map[string]any{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "RoleBinding", + "metadata": map[string]any{"name": "sa-missing-role"}, + "subjects": []map[string]any{ + {"kind": "ServiceAccount", "name": "default"}, + }, + "roleRef": map[string]any{ + "kind": "Role", "name": "does-not-exist", "apiGroup": "rbac.authorization.k8s.io", + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/rbac.authorization.k8s.io/v1/namespaces/default/rolebindings", rbBody) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create RoleBinding: status %d", resp.StatusCode) + } + resp.Body.Close() + + if sarAllowed(t, base, "system:serviceaccount:default:default", "get", "pods", "default") { + t.Error("expected denial: RoleBinding references a Role that doesn't exist") + } +} + +func TestSubjectAccessReview_NonResourceAttributesNoOpinion(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{ + "apiVersion": "authorization.k8s.io/v1", + "kind": "SubjectAccessReview", + "spec": map[string]any{ + "user": "alice", + "nonResourceAttributes": map[string]any{"verb": "get", "path": "/healthz"}, + }, + }) + + resp := do(t, http.MethodPost, base+"/apis/authorization.k8s.io/v1/subjectaccessreviews", body) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("SubjectAccessReview: status %d", resp.StatusCode) + } + + m := decodeMap(t, resp.Body) + status, _ := m["status"].(map[string]any) + + if allowed, _ := status["allowed"].(bool); allowed { + t.Error("expected nonResourceAttributes review to not be allowed") + } +} + +func TestSubjectAccessReview_DiscoveryAdvertisesGroup(t *testing.T) { + base, cleanup := newFixture(t) + t.Cleanup(cleanup) + + resp := do(t, http.MethodGet, base+"/apis/authorization.k8s.io/v1", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("discovery: status %d", resp.StatusCode) + } + + m := decodeMap(t, resp.Body) + + resources, _ := m["resources"].([]any) + if len(resources) == 0 { + t.Fatal("expected subjectaccessreviews to be advertised") + } +} diff --git a/services/kubernetes/reconcile.go b/services/kubernetes/reconcile.go index b67f07bf..bbf2f796 100644 --- a/services/kubernetes/reconcile.go +++ b/services/kubernetes/reconcile.go @@ -7,7 +7,6 @@ import ( "reflect" "sort" "strconv" - "time" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -46,7 +45,7 @@ func (s *ClusterState) markPodRunningLocked(pod *corev1.Pod) { return } - now := metav1.NewTime(time.Now()) + now := s.now() if pod.Status.PodIP == "" { ip := s.allocatePodIPLocked() @@ -115,7 +114,7 @@ func (s *ClusterState) buildControllerPod( Name: name, Namespace: namespace, UID: types.UID(newUID()), - CreationTimestamp: metav1.NewTime(time.Now()), + CreationTimestamp: s.now(), ResourceVersion: "1", Labels: labels, Annotations: tmpl.Annotations, @@ -308,7 +307,7 @@ func (s *ClusterState) writeEndpointsLocked(svc *corev1.Service, subsets []corev existed := ep != nil if !existed { - ep = newEndpointsObject(svc.Namespace, svc.Name) + ep = s.newEndpointsObject(svc.Namespace, svc.Name) } if existed && reflect.DeepEqual(ep.Subsets, subsets) { @@ -331,7 +330,7 @@ const serviceNameLabel = "kubernetes.io/service-name" // EndpointSlice, so EndpointSlice-mode consumers (kube-proxy, Gateway API) see // the same backends the typed Endpoints object carries. func (s *ClusterState) syncEndpointSliceLocked(svc *corev1.Service, addrs []corev1.EndpointAddress) { - store := s.reg.stores[regKey(apiGroupDiscovery, "v1", "endpointslices")] + store := s.reg.getStore(apiGroupDiscovery, "v1", "endpointslices") if store == nil { return } @@ -415,21 +414,17 @@ func labelsMatch(selector, labels map[string]string) bool { // ReplicaSet object is not yet materialized; Pods are owned by the Deployment // directly — a documented simplification.) Callers hold s.mu. func (s *ClusterState) reconcileDeploymentLocked(dep *appsv1.Deployment) { - desired := 1 + requested := 1 if dep.Spec.Replicas != nil { - desired = int(*dep.Spec.Replicas) + requested = int(*dep.Spec.Replicas) } - desired = clampPodCount(desired) + desired := clampPodCount(requested) + noteClampMeta(&dep.ObjectMeta, requested, desired) - owner := metav1.OwnerReference{ - APIVersion: "apps/v1", Kind: "Deployment", Name: dep.Name, UID: dep.UID, - Controller: boolPtr(true), BlockOwnerDeletion: boolPtr(true), - } - - // syncScaledPods returns a count already clamped to maxReconciledPods, so the - // int32 conversion cannot overflow. - ready := int32(s.syncScaledPods(dep.Namespace, dep.Name, owner, dep.Spec.Template, desired)) //nolint:gosec // bounded by maxReconciledPods + // Interpose a ReplicaSet (Deployment→RS→Pod) rather than owning Pods + // directly, matching real Deployment topology. + ready := s.syncDeploymentReplicaSetLocked(dep, desired) dep.Status.Replicas = ready dep.Status.ReadyReplicas = ready @@ -447,14 +442,20 @@ func (s *ClusterState) reconcileDeploymentLocked(dep *appsv1.Deployment) { // --- Registry reconcile hooks (apps/v1 workloads + PVC) ---------------------- func reconcileReplicaSet(s *ClusterState, obj *unstructured.Unstructured) { + requested := rawReplicasOf(obj) + desired := clampPodCount(requested) + noteClampUnstructured(obj, requested, desired) + ready := s.syncScaledPods(obj.GetNamespace(), obj.GetName(), ownerRefOf(obj), - podTemplateFromUnstructured(obj), replicasOf(obj)) + podTemplateFromUnstructured(obj), desired) setWorkloadStatus(obj, ready) s.resyncEndpointsForNamespaceLocked(obj.GetNamespace()) } func reconcileStatefulSet(s *ClusterState, obj *unstructured.Unstructured) { - desired := replicasOf(obj) + requested := rawReplicasOf(obj) + desired := clampPodCount(requested) + noteClampUnstructured(obj, requested, desired) names := make([]string, desired) for i := range names { @@ -468,7 +469,14 @@ func reconcileStatefulSet(s *ClusterState, obj *unstructured.Unstructured) { } func reconcileDaemonSet(s *ClusterState, obj *unstructured.Unstructured) { - names := []string{obj.GetName() + "-" + nodeName} + // A DaemonSet runs one Pod per node whose labels satisfy the template's + // nodeSelector. With a single synthetic node, a non-matching selector yields + // zero Pods (rather than the previous unconditional one). + var names []string + if s.daemonSetSchedulesToNode(obj) { + names = []string{obj.GetName() + "-" + nodeName} + } + ready := int64(s.syncStablePods(obj.GetNamespace(), ownerRefOf(obj), podTemplateFromUnstructured(obj), names)) set := func(field string) { _ = unstructured.SetNestedField(obj.Object, ready, "status", field) } @@ -482,6 +490,41 @@ func reconcileDaemonSet(s *ClusterState, obj *unstructured.Unstructured) { s.resyncEndpointsForNamespaceLocked(obj.GetNamespace()) } +// daemonSetSchedulesToNode reports whether the DaemonSet's template nodeSelector +// matches the single synthetic node's labels (empty selector always matches). +func (s *ClusterState) daemonSetSchedulesToNode(obj *unstructured.Unstructured) bool { + sel, _, _ := unstructured.NestedStringMap(obj.Object, "spec", "template", "spec", "nodeSelector") + if len(sel) == 0 { + return true + } + + labels := s.nodeLabels() + for k, v := range sel { + if labels[k] != v { + return false + } + } + + return true +} + +// nodeLabels returns the synthetic node's labels (nil if the node is absent). +func (s *ClusterState) nodeLabels() map[string]string { + st := s.reg.getStore("", "v1", "nodes") + if st == nil { + return nil + } + + node := st.items[objKey("", nodeName)] + if node == nil { + return nil + } + + labels, _, _ := unstructured.NestedStringMap(node.Object, "metadata", "labels") + + return labels +} + // reconcilePVC marks a PersistentVolumeClaim Bound — cloudemu dynamically // "provisions" storage immediately (there is no real volume plugin). func reconcilePVC(_ *ClusterState, obj *unstructured.Unstructured) { @@ -509,33 +552,53 @@ func reconcileIngress(_ *ClusterState, obj *unstructured.Unstructured) { []any{map[string]any{"ip": ingressLBIP}}, "status", "loadBalancer", "ingress") } -// reconcileJob runs a Job to completion: it creates `completions` Pods (default -// 1) that go straight to Succeeded, and marks the Job Complete. +// reconcileJob runs a Job to completion: it reconciles the Job's owned Pods to +// exactly `completions` (default 1) Succeeded Pods and marks the Job Complete. +// Reconciling to the exact count — rather than only topping up — means a lowered +// completions drops the surplus Pods, so status.succeeded reflects the current +// spec instead of overstating it with Pods from a previous, larger run. func reconcileJob(s *ClusterState, obj *unstructured.Unstructured) { - completions := 1 + requested := 1 if c, found, _ := unstructured.NestedInt64(obj.Object, "spec", "completions"); found && c > 0 { - completions = clampPodCount(int(c)) + requested = int(c) } + completions := clampPodCount(requested) + noteClampUnstructured(obj, requested, completions) + ns := obj.GetNamespace() owner := ownerRefOf(obj) tmpl := podTemplateFromUnstructured(obj) - // Count owned Pods once, then top up — re-scanning s.pods each iteration - // would make this O(n²). - have := len(s.podsOwnedByLocked(ns, owner.UID)) - for ; have < completions; have++ { + owned := s.podsOwnedByLocked(ns, owner.UID) + + // Shrink first: drop Pods above the desired completions (highest-sorted + // names) so a re-reconcile after a lowered completions cleans up. + for len(owned) > completions { + last := owned[len(owned)-1] + delete(s.pods, podKey(ns, last.Name)) + s.wPods.publish(EventDeleted, ns, *last.DeepCopy()) + + owned = owned[:len(owned)-1] + } + + // Top up the rest with Pods driven straight to Succeeded. + for len(owned) < completions { pod := s.buildControllerPod(ns, obj.GetName()+"-"+shortID(), tmpl, owner) s.markPodSucceededLocked(pod) s.pods[podKey(ns, pod.Name)] = pod s.wPods.publish(EventAdded, ns, *pod.DeepCopy()) + owned = append(owned, pod) } - succeeded := int64(len(s.podsOwnedByLocked(ns, owner.UID))) + succeeded := int64(len(owned)) _ = unstructured.SetNestedField(obj.Object, succeeded, "status", "succeeded") _ = unstructured.SetNestedField(obj.Object, int64(0), "status", "active") - _ = unstructured.SetNestedSlice(obj.Object, - []any{map[string]any{"type": "Complete", "status": "True"}}, "status", "conditions") + + if completions > 0 { + _ = unstructured.SetNestedSlice(obj.Object, + []any{map[string]any{"type": "Complete", "status": "True"}}, "status", "conditions") + } } // markPodSucceededLocked drives a Pod to the completed (Succeeded) terminal @@ -543,7 +606,7 @@ func reconcileJob(s *ClusterState, obj *unstructured.Unstructured) { func (s *ClusterState) markPodSucceededLocked(pod *corev1.Pod) { s.markPodRunningLocked(pod) - now := metav1.NewTime(time.Now()) + now := s.now() pod.Status.Phase = corev1.PodSucceeded for i := range pod.Status.ContainerStatuses { @@ -564,7 +627,7 @@ func (s *ClusterState) syncStatefulSetPVCsLocked(sts *unstructured.Unstructured, return } - store := s.reg.stores[regKey("", "v1", "persistentvolumeclaims")] + store := s.reg.getStore("", "v1", "persistentvolumeclaims") if store == nil { return } @@ -600,7 +663,7 @@ func (s *ClusterState) syncStatefulSetPVCsLocked(sts *unstructured.Unstructured, "status": map[string]any{"phase": "Bound"}, }} pvc.SetUID(types.UID(newUID())) - pvc.SetCreationTimestamp(metav1.NewTime(time.Now())) + pvc.SetCreationTimestamp(s.now()) pvc.SetOwnerReferences([]metav1.OwnerReference{ownerRefOf(sts)}) store.stampRVLocked(pvc) store.items[key] = pvc @@ -669,13 +732,59 @@ func clampPodCount(n int) int { } } -func replicasOf(obj *unstructured.Unstructured) int { +// clampAnnotation records, on an object whose spec asked for more Pods than the +// reconciler will materialize, exactly how many were requested vs materialized. +// The spec is preserved unchanged; this annotation is the only surfacing of the +// cap, so a caller can see why status.replicas is below spec instead of the +// clamp being silent. +const clampAnnotation = "cloudemu.io/pod-count-clamped" + +func clampNote(requested, materialized int) string { + return fmt.Sprintf("requested=%d materialized=%d cap=%d", requested, materialized, maxReconciledPods) +} + +// noteClampUnstructured stamps clampAnnotation on a registry-backed object. +func noteClampUnstructured(obj *unstructured.Unstructured, requested, materialized int) { + if requested <= materialized { + return + } + + anns := obj.GetAnnotations() + if anns == nil { + anns = make(map[string]string, 1) + } + + anns[clampAnnotation] = clampNote(requested, materialized) + obj.SetAnnotations(anns) +} + +// noteClampMeta stamps clampAnnotation on a typed object's ObjectMeta. +func noteClampMeta(meta *metav1.ObjectMeta, requested, materialized int) { + if requested <= materialized { + return + } + + if meta.Annotations == nil { + meta.Annotations = make(map[string]string, 1) + } + + meta.Annotations[clampAnnotation] = clampNote(requested, materialized) +} + +// rawReplicasOf returns the object's requested spec.replicas WITHOUT clamping +// (default 1, negatives floored to 0), so callers can compare it against the +// clamped count and surface the difference via noteClamp. +func rawReplicasOf(obj *unstructured.Unstructured) int { n, found, _ := unstructured.NestedInt64(obj.Object, "spec", "replicas") if !found { return 1 } - return clampPodCount(int(n)) + if n < 0 { + return 0 + } + + return int(n) } func podTemplateFromUnstructured(obj *unstructured.Unstructured) corev1.PodTemplateSpec { diff --git a/services/kubernetes/registry.go b/services/kubernetes/registry.go index 271eea2d..578b8c24 100644 --- a/services/kubernetes/registry.go +++ b/services/kubernetes/registry.go @@ -8,10 +8,9 @@ import ( "sort" "strconv" "strings" - "time" + "sync" jsonpatch "gopkg.in/evanphx/json-patch.v4" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" @@ -33,6 +32,9 @@ type resourceDef struct { // reconcile runs (under s.mu) after a successful create/update/patch to // materialize children and refresh status. nil = plain CRUD store. reconcile func(s *ClusterState, obj *unstructured.Unstructured) + // onDelete runs (under s.mu) after a successful delete. Used by the CRD kind + // to deregister the custom resource's store and cascade-delete its objects. + onDelete func(s *ClusterState, obj *unstructured.Unstructured) } func (d *resourceDef) apiVersion() string { @@ -54,8 +56,13 @@ type registryStore struct { rv int // monotonic resourceVersion source, bumped on every mutation } -// registry maps a group/version/plural to its store. +// registry maps a group/version/plural to its store. The stores map is fixed at +// construction for built-in kinds but grows/shrinks at runtime as CRDs are +// created/deleted, so all access is guarded by mu. (Store CONTENTS — items/rv — +// remain guarded by the owning ClusterState's mutex; mu guards only the set of +// stores.) type registry struct { + mu sync.RWMutex stores map[string]*registryStore } @@ -79,9 +86,73 @@ func (r *registry) lookup(route *Route) *registryStore { return nil } + r.mu.RLock() + defer r.mu.RUnlock() + return r.stores[regKey(route.APIGroup, route.APIVersion, route.Resource)] } +// getStore returns the store for a group/version/plural, or nil. Safe against +// concurrent CRD add/remove. +func (r *registry) getStore(group, version, plural string) *registryStore { + r.mu.RLock() + defer r.mu.RUnlock() + + return r.stores[regKey(group, version, plural)] +} + +// addStore materializes a store for a (CRD-defined) kind if absent, returning +// the store. Idempotent — re-applying a CRD keeps the existing store and its +// objects. +func (r *registry) addStore(d *resourceDef) *registryStore { + r.mu.Lock() + defer r.mu.Unlock() + + key := regKey(d.group, d.version, d.plural) + if st, ok := r.stores[key]; ok { + return st + } + + st := ®istryStore{def: d, items: make(map[string]*unstructured.Unstructured), watch: newBroadcaster()} + r.stores[key] = st + + return st +} + +// removeStore drops a (CRD-defined) kind's store. Idempotent. +func (r *registry) removeStore(group, version, plural string) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.stores, regKey(group, version, plural)) +} + +// allDefs returns every live store's resourceDef, sorted for deterministic +// discovery output. Includes both built-in and CRD-added kinds. +func (r *registry) allDefs() []*resourceDef { + r.mu.RLock() + defer r.mu.RUnlock() + + out := make([]*resourceDef, 0, len(r.stores)) + for _, st := range r.stores { + out = append(out, st.def) + } + + sort.Slice(out, func(i, j int) bool { + if out[i].group != out[j].group { + return out[i].group < out[j].group + } + + if out[i].version != out[j].version { + return out[i].version < out[j].version + } + + return out[i].plural < out[j].plural + }) + + return out +} + func objKey(namespace, name string) string { return namespace + "/" + name } // serveRegistry is the generic handler entry point for a registry-backed kind. @@ -138,7 +209,7 @@ func (s *ClusterState) serveRegistryItem(w http.ResponseWriter, r *http.Request, case http.MethodPatch: s.registryPatch(w, r, st, route.Namespace, route.Name) case http.MethodDelete: - s.registryDelete(w, st, route.Namespace, route.Name) + s.registryDelete(w, r, st, route.Namespace, route.Name) default: writeMethodNotAllowed(w, "k8s api: "+st.def.plural+" item: method not allowed: "+r.Method) } @@ -154,8 +225,13 @@ func (s *ClusterState) registryList(w http.ResponseWriter, r *http.Request, st * s.mu.RLock() sub := st.watch.subscribe(namespace) items := st.snapshotLocked(namespace, r) + rv := st.rv s.mu.RUnlock() - streamWatch(r.Context(), w, sub, items, keep) + streamWatch(r.Context(), w, sub, items, keep, watchOpts{ + resume: watchResume(r), + bookmarks: watchBookmarksEnabled(r), + bookmarkObj: registryBookmark(st, rv), + }) return } @@ -165,15 +241,33 @@ func (s *ClusterState) registryList(w http.ResponseWriter, r *http.Request, st * items := st.snapshotLocked(namespace, r) + items, cont, ok := listPage(items, w, r) + if !ok { + return + } + list := &unstructured.UnstructuredList{} list.SetAPIVersion(st.def.apiVersion()) list.SetKind(st.def.listKind) list.SetResourceVersion(strconv.Itoa(st.rv)) + list.SetContinue(cont) list.Items = items writeJSON(w, http.StatusOK, list) } +// registryBookmark builds the minimal object a BOOKMARK watch event carries for +// a registry-backed kind: just the kind's apiVersion/kind and the store's +// current resourceVersion. +func registryBookmark(st *registryStore, rv int) *unstructured.Unstructured { + bm := &unstructured.Unstructured{} + bm.SetAPIVersion(st.def.apiVersion()) + bm.SetKind(st.def.kind) + bm.SetResourceVersion(strconv.Itoa(rv)) + + return bm +} + // snapshotLocked returns a sorted, selector-filtered copy of the store's items // in namespace ("" = all). Callers hold s.mu. func (st *registryStore) snapshotLocked(namespace string, r *http.Request) []unstructured.Unstructured { @@ -239,8 +333,37 @@ func (s *ClusterState) registryCreate(w http.ResponseWriter, r *http.Request, st obj.SetAPIVersion(st.def.apiVersion()) obj.SetKind(st.def.kind) obj.SetUID(types.UID(newUID())) - obj.SetCreationTimestamp(metav1.NewTime(time.Now())) + obj.SetCreationTimestamp(s.now()) obj.SetGeneration(1) + + // Admission (opt-in) is the first gate — before dry-run echoes or quota is + // reserved, so a denied create leaks neither. + if handled := s.admit(w, opCreate, st.def.gvr(), obj); handled { + return + } + + if isDryRun(r) { + // A dry-run must report the same 403 a real create would when the + // namespace is at its quota limit — check (without reserving) before echo. + if status := s.checkQuotaLocked(namespace, st.def.kind, st.def.plural); status != nil { + writeJSON(w, int(status.Code), status) + + return + } + + obj.SetResourceVersion(strconv.Itoa(st.rv + 1)) + writeJSON(w, http.StatusCreated, obj) + + return + } + + // Quota is reserved only on a real (non-dry-run) create. + if status := s.checkAndReserveQuota(namespace, st.def.kind, st.def.plural); status != nil { + writeJSON(w, int(status.Code), status) + + return + } + st.stampRVLocked(obj) st.items[key] = obj @@ -294,6 +417,9 @@ func (s *ClusterState) registryUpdate(w http.ResponseWriter, r *http.Request, st in.SetCreationTimestamp(cur.GetCreationTimestamp()) in.SetAPIVersion(st.def.apiVersion()) in.SetKind(st.def.kind) + // deletionTimestamp is server-owned: a PUT can drop a finalizer but must not + // resurrect a Terminating object by omitting the timestamp. + in.SetDeletionTimestamp(cur.GetDeletionTimestamp()) // Bump generation when the spec changed — controllers compare it against // status.observedGeneration. if specChanged(cur, in) { @@ -302,8 +428,33 @@ func (s *ClusterState) registryUpdate(w http.ResponseWriter, r *http.Request, st in.SetGeneration(cur.GetGeneration()) } + if handled := s.admit(w, opUpdate, st.def.gvr(), in); handled { + return + } + + // A plain PUT takes/shares ownership: preserve prior managedFields and record + // an Update entry for this fieldManager covering the fields it set. + s.stampUpdateOwnership(in, managedFieldsOf(cur), updateFieldManager(r), st.def.apiVersion(), ownedLeaves(in.Object)) + + if isDryRun(r) { + in.SetResourceVersion(strconv.Itoa(st.rv + 1)) + writeJSON(w, http.StatusOK, in) + + return + } + st.stampRVLocked(in) + // Last finalizer removed on a Terminating object → complete the delete + // (same teardown the immediate-delete path runs: cascade + onDelete + quota). + if finalizersDrainedUnstructured(in) { + s.teardownRegistryObjectLocked(st, objKey(namespace, name), in) + st.watch.publish(EventDeleted, in.GetNamespace(), *in.DeepCopy()) + writeJSON(w, http.StatusOK, in) + + return + } + st.items[objKey(namespace, name)] = in if st.def.reconcile != nil { @@ -325,17 +476,67 @@ func (s *ClusterState) registryPatch(w http.ResponseWriter, r *http.Request, st return } - patched, ok := s.applyUnstructuredPatch(w, r, cur) + // Snapshot server-owned metadata before the patch so an RFC-7396 null-delete + // (e.g. `{"metadata":{"deletionTimestamp":null}}`) cannot resurrect or + // re-identify the object — mirrors the PUT path's guard. + prevDeletion := cur.GetDeletionTimestamp() + prevUID := cur.GetUID() + prevCreation := cur.GetCreationTimestamp() + + // Server-side apply tracks field ownership + conflicts (managedFields); every + // other patch content-type is a plain merge/strategic/JSONPatch. + var patched *unstructured.Unstructured + + if r.Header.Get("Content-Type") == contentTypeApplyPatch { + patched, ok = s.serverSideApply(w, r, st, cur) + } else { + patched, ok = s.applyUnstructuredPatch(w, r, cur) + } + if !ok { return } + patched.SetDeletionTimestamp(prevDeletion) + patched.SetUID(prevUID) + patched.SetCreationTimestamp(prevCreation) + + // A non-apply patch takes/shares ownership: record an Update entry for its + // fieldManager covering the fields it changed. Apply-patch handled its own + // managedFields in serverSideApply. + if r.Header.Get("Content-Type") != contentTypeApplyPatch { + s.stampUpdateOwnership(patched, managedFieldsOf(cur), updateFieldManager(r), + st.def.apiVersion(), changedLeaves(cur.Object, patched.Object)) + } + if specChanged(cur, patched) { patched.SetGeneration(cur.GetGeneration() + 1) } + if handled := s.admit(w, opUpdate, st.def.gvr(), patched); handled { + return + } + + if isDryRun(r) { + patched.SetResourceVersion(strconv.Itoa(st.rv + 1)) + writeJSON(w, http.StatusOK, patched) + + return + } + st.stampRVLocked(patched) + // A patch that removes the last finalizer from a Terminating object completes + // the delete (the patch was applied onto cur, so it inherits its + // deletionTimestamp), running the same teardown as the immediate-delete path. + if finalizersDrainedUnstructured(patched) { + s.teardownRegistryObjectLocked(st, objKey(namespace, name), patched) + st.watch.publish(EventDeleted, patched.GetNamespace(), *patched.DeepCopy()) + writeJSON(w, http.StatusOK, patched) + + return + } + st.items[objKey(namespace, name)] = patched if st.def.reconcile != nil { @@ -346,7 +547,7 @@ func (s *ClusterState) registryPatch(w http.ResponseWriter, r *http.Request, st writeJSON(w, http.StatusOK, patched) } -func (s *ClusterState) registryDelete(w http.ResponseWriter, st *registryStore, namespace, name string) { +func (s *ClusterState) registryDelete(w http.ResponseWriter, r *http.Request, st *registryStore, namespace, name string) { s.mu.Lock() defer s.mu.Unlock() @@ -359,15 +560,52 @@ func (s *ClusterState) registryDelete(w http.ResponseWriter, st *registryStore, return } - delete(st.items, key) + if isDryRun(r) { + writeJSON(w, http.StatusOK, obj.DeepCopy()) + + return + } + + // Finalizer-gated deletion: an object with finalizers goes Terminating + // (deletionTimestamp stamped) and stays until the last finalizer is removed + // via a later update/patch, rather than being deleted now. + if s.markForDeletionUnstructured(obj) { + st.stampRVLocked(obj) + st.watch.publish(EventModified, obj.GetNamespace(), *obj.DeepCopy()) + writeJSON(w, http.StatusOK, obj.DeepCopy()) + + return + } + st.bumpRVLocked() + s.teardownRegistryObjectLocked(st, key, obj) + + st.watch.publish(EventDeleted, obj.GetNamespace(), *obj.DeepCopy()) + writeJSON(w, http.StatusOK, obj.DeepCopy()) +} + +// teardownRegistryObjectLocked performs the final removal of a registry object +// once it is finalizer-free (never had finalizers, or the last one just +// drained): it drops the object, cascades to anything it owns, runs +// kind-specific teardown (the CRD kind deregisters its CR store + discovery +// entry here), and recomputes any quota it counted against. Shared by the +// immediate-delete path and the finalizer-drain completions in registryUpdate/ +// registryPatch so every path finalizes identically. Callers hold s.mu. +func (s *ClusterState) teardownRegistryObjectLocked(st *registryStore, key string, obj *unstructured.Unstructured) { + delete(st.items, key) // Cascade: garbage-collect anything this object owns (its controlled Pods // and any registry-backed children carrying its ownerReference). s.garbageCollectLocked(obj.GetUID()) - st.watch.publish(EventDeleted, obj.GetNamespace(), *obj.DeepCopy()) - writeJSON(w, http.StatusOK, obj.DeepCopy()) + // Kind-specific cleanup (the CRD kind deregisters its CR store here). + if st.def.onDelete != nil { + st.def.onDelete(s, obj) + } + + // A quota-counted object going away must drop status.used back to the live + // count (recompute so a cascade that removed several at once stays correct). + s.releaseQuotaLocked(obj.GetNamespace(), st.def.kind, st.def.plural) } // stampRVLocked bumps the store's resourceVersion counter and stamps it on obj. @@ -502,30 +740,57 @@ const ( fieldMetadataNamespace = "metadata.namespace" fieldStatusPhase = "status.phase" fieldSpecNodeName = "spec.nodeName" + // Event field selectors — `kubectl get events --field-selector` and + // controllers filtering their own Events rely on these. Without them the + // generic store fell closed (returned nothing) for any Event filter. + fieldInvolvedName = "involvedObject.name" + fieldInvolvedNamespace = "involvedObject.namespace" + fieldInvolvedKind = "involvedObject.kind" + fieldInvolvedUID = "involvedObject.uid" + fieldEventReason = "reason" + fieldEventType = "type" ) func matchesFields(obj *unstructured.Unstructured, fields map[string]string) bool { for k, v := range fields { - switch k { - case fieldMetadataName: - if obj.GetName() != v { - return false - } - case fieldMetadataNamespace: - if obj.GetNamespace() != v { - return false - } - case fieldStatusPhase: - phase, _, _ := unstructured.NestedString(obj.Object, "status", "phase") - if phase != v { - return false - } - default: - // Unknown field selector: match nothing rather than silently - // returning everything (a data-correctness hazard for callers). + if !matchesField(obj, k, v) { return false } } return true } + +// matchesField answers a single field-selector clause. Unknown keys fail closed +// (match nothing) rather than silently returning everything — a data-correctness +// hazard for callers that expect the filter to be honored. +func matchesField(obj *unstructured.Unstructured, key, want string) bool { + switch key { + case fieldMetadataName: + return obj.GetName() == want + case fieldMetadataNamespace: + return obj.GetNamespace() == want + case fieldStatusPhase: + return nestedStringField(obj, want, "status", "phase") + case fieldInvolvedName: + return nestedStringField(obj, want, "involvedObject", "name") + case fieldInvolvedNamespace: + return nestedStringField(obj, want, "involvedObject", "namespace") + case fieldInvolvedKind: + return nestedStringField(obj, want, "involvedObject", "kind") + case fieldInvolvedUID: + return nestedStringField(obj, want, "involvedObject", "uid") + case fieldEventReason: + return nestedStringField(obj, want, "reason") + case fieldEventType: + return nestedStringField(obj, want, "type") + default: + return false + } +} + +func nestedStringField(obj *unstructured.Unstructured, want string, path ...string) bool { + got, _, _ := unstructured.NestedString(obj.Object, path...) + + return got == want +} diff --git a/services/kubernetes/registry_defs.go b/services/kubernetes/registry_defs.go index f9639335..c1890237 100644 --- a/services/kubernetes/registry_defs.go +++ b/services/kubernetes/registry_defs.go @@ -3,12 +3,20 @@ package kubernetes // API group names for the registry-backed kinds. apps and policy have their own // constants next to their typed handlers (apiGroupApps, apiGroupPolicy). const ( - apiGroupBatch = "batch" - apiGroupNetworking = "networking.k8s.io" - apiGroupRBAC = "rbac.authorization.k8s.io" - apiGroupStorage = "storage.k8s.io" - apiGroupAutoscaling = "autoscaling" - apiGroupDiscovery = "discovery.k8s.io" + apiGroupBatch = "batch" + apiGroupNetworking = "networking.k8s.io" + apiGroupRBAC = "rbac.authorization.k8s.io" + apiGroupStorage = "storage.k8s.io" + apiGroupAutoscaling = "autoscaling" + apiGroupDiscovery = "discovery.k8s.io" + apiGroupExtensions = "apiextensions.k8s.io" + apiGroupAdmissionRegistration = "admissionregistration.k8s.io" + + // Plural resource segments for the two webhook config kinds — referenced + // by admission.go when it looks up the registry store to find configured + // webhooks. + pluralMutatingWebhooks = "mutatingwebhookconfigurations" + pluralValidatingWebhooks = "validatingwebhookconfigurations" ) // registeredResources lists every registry-backed kind. Adding a Kubernetes @@ -28,6 +36,8 @@ func registeredResources() []*resourceDef { autoscalingRegistryDefs(), discoveryRegistryDefs(), coreRegistryDefs(), + crdRegistryDefs(), + admissionRegistryDefs(), ) } @@ -113,6 +123,7 @@ func autoscalingRegistryDefs() []*resourceDef { { group: apiGroupAutoscaling, version: "v2", kind: "HorizontalPodAutoscaler", listKind: "HorizontalPodAutoscalerList", plural: "horizontalpodautoscalers", namespaced: true, hasStatus: true, + reconcile: reconcileHPA, }, } } @@ -155,6 +166,23 @@ func coreRegistryDefs() []*resourceDef { } } +// admissionRegistryDefs registers the two webhook config kinds as plain +// stored (no reconcile) kinds — this alone makes `kubectl apply -f +// webhook.yaml` round-trip. Whether they are actually invoked on writes is +// controlled separately by APIServer.SetAdmissionEnabled (see admission.go). +func admissionRegistryDefs() []*resourceDef { + return []*resourceDef{ + { + group: apiGroupAdmissionRegistration, version: "v1", kind: "MutatingWebhookConfiguration", + listKind: "MutatingWebhookConfigurationList", plural: pluralMutatingWebhooks, namespaced: false, + }, + { + group: apiGroupAdmissionRegistration, version: "v1", kind: "ValidatingWebhookConfiguration", + listKind: "ValidatingWebhookConfigurationList", plural: pluralValidatingWebhooks, namespaced: false, + }, + } +} + func concat(groups ...[]*resourceDef) []*resourceDef { var out []*resourceDef for _, g := range groups { diff --git a/services/kubernetes/registry_ops.go b/services/kubernetes/registry_ops.go index 0f596184..01925f2d 100644 --- a/services/kubernetes/registry_ops.go +++ b/services/kubernetes/registry_ops.go @@ -36,6 +36,16 @@ func (s *ClusterState) garbageCollectLocked(owner types.UID) { continue } + // A child carrying finalizers goes Terminating (like a normal + // finalizer-gated delete), not hard-reaped. It keeps owning its own + // children until drained, so it is not enqueued as a deleted owner. + if s.markForDeletionUnstructured(obj) { + st.stampRVLocked(obj) + st.watch.publish(EventModified, obj.GetNamespace(), *obj.DeepCopy()) + + continue + } + uid := obj.GetUID() owners[uid] = true @@ -51,19 +61,33 @@ func (s *ClusterState) garbageCollectLocked(owner types.UID) { touched := map[string]bool{} for key, pod := range s.pods { - if ownedByAny(pod.OwnerReferences, owners) { - delete(s.pods, key) + if !ownedByAny(pod.OwnerReferences, owners) { + continue + } - touched[pod.Namespace] = true + // Same finalizer gating for owned Pods: a Pod with finalizers is marked + // Terminating and left in place until its finalizers drain. + if s.markForDeletion(&pod.ObjectMeta) { + pod.ResourceVersion = bumpResourceVersion(pod.ResourceVersion) + s.wPods.publish(EventModified, pod.Namespace, *pod.DeepCopy()) - s.wPods.publish(EventDeleted, pod.Namespace, *pod.DeepCopy()) + continue } + + delete(s.pods, key) + + touched[pod.Namespace] = true + + s.wPods.publish(EventDeleted, pod.Namespace, *pod.DeepCopy()) } // The endpoints controller must drop the addresses of the Pods we just - // garbage-collected, or a Service keeps pointing at gone Pods. + // garbage-collected, or a Service keeps pointing at gone Pods; and the Pod + // quota's status.used must fall back to the live count in each namespace we + // reaped from. for ns := range touched { s.resyncEndpointsForNamespaceLocked(ns) + s.releaseQuotaLocked(ns, "Pod", resourcePods) } } diff --git a/services/kubernetes/route.go b/services/kubernetes/route.go index 77712485..82a6393d 100644 --- a/services/kubernetes/route.go +++ b/services/kubernetes/route.go @@ -26,6 +26,15 @@ const ( subresourceScale = "scale" ) +// Pod subresource path segments. `log` is served with synthetic output; the +// streaming ones (exec/attach/portforward) return a typed "not implemented". +const ( + subresourcePodLog = "log" + subresourcePodExec = "exec" + subresourcePodAttach = "attach" + subresourcePodPortForward = "portforward" +) + // watchQuery is the ?watch=true value clients pass to upgrade a list // request into a stream. Centralized so dispatchers don't all hold a // "true" literal. diff --git a/services/kubernetes/secret.go b/services/kubernetes/secret.go index 03c71e92..e6c2c2d3 100644 --- a/services/kubernetes/secret.go +++ b/services/kubernetes/secret.go @@ -36,7 +36,7 @@ func (s *ClusterState) serveSecrets(w http.ResponseWriter, r *http.Request, rout return } - s.listSecretsAllNamespaces(w) + s.listSecretsAllNamespaces(w, r) return } @@ -65,7 +65,7 @@ func (s *ClusterState) serveSecretCollection(w http.ResponseWriter, r *http.Requ return } - s.listSecrets(w, namespace) + s.listSecrets(w, r, namespace) case http.MethodPost: s.createSecret(w, r, namespace) default: @@ -91,7 +91,7 @@ func (s *ClusterState) serveSecretItem(w http.ResponseWriter, r *http.Request, n case http.MethodPatch: s.patchSecret(w, r, namespace, name) case http.MethodDelete: - s.deleteSecret(w, namespace, name) + s.deleteSecret(w, r, namespace, name) default: writeMethodNotAllowed(w, "k8s api: secret item: method not allowed: "+r.Method) } @@ -124,37 +124,53 @@ func (s *ClusterState) createSecret(w http.ResponseWriter, r *http.Request, name return } - stamp(&in.ObjectMeta) + s.stamp(&in.ObjectMeta) in.TypeMeta = metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"} if in.Type == "" { in.Type = corev1.SecretTypeOpaque } + if isDryRun(r) { + writeJSON(w, http.StatusCreated, &in) + + return + } + sec := in s.secrets[key] = &sec s.wSecrets.publish(EventAdded, namespace, *sec.DeepCopy()) writeJSON(w, http.StatusCreated, &sec) } -func (s *ClusterState) listSecrets(w http.ResponseWriter, namespace string) { +func (s *ClusterState) listSecrets(w http.ResponseWriter, r *http.Request, namespace string) { s.mu.RLock() defer s.mu.RUnlock() - items := s.collectSecretsLocked(namespace) + items, cont, ok := listPage(s.collectSecretsLocked(namespace), w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &corev1.SecretList{ TypeMeta: metav1.TypeMeta{Kind: "SecretList", APIVersion: "v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } -func (s *ClusterState) listSecretsAllNamespaces(w http.ResponseWriter) { +func (s *ClusterState) listSecretsAllNamespaces(w http.ResponseWriter, r *http.Request) { s.mu.RLock() defer s.mu.RUnlock() - items := s.collectSecretsLocked("") + items, cont, ok := listPage(s.collectSecretsLocked(""), w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &corev1.SecretList{ TypeMeta: metav1.TypeMeta{Kind: "SecretList", APIVersion: "v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } @@ -228,6 +244,12 @@ func (s *ClusterState) updateSecret(w http.ResponseWriter, r *http.Request, name in.Type = cur.Type } + if isDryRun(r) { + writeJSON(w, http.StatusOK, &in) + + return + } + sec := in s.secrets[key] = &sec s.wSecrets.publish(EventModified, namespace, *sec.DeepCopy()) @@ -257,12 +279,19 @@ func (s *ClusterState) patchSecret(w http.ResponseWriter, r *http.Request, names } patched.ResourceVersion = bumpResourceVersion(cur.ResourceVersion) + + if isDryRun(r) { + writeJSON(w, http.StatusOK, patched) + + return + } + s.secrets[key] = patched s.wSecrets.publish(EventModified, namespace, *patched.DeepCopy()) writeJSON(w, http.StatusOK, patched) } -func (s *ClusterState) deleteSecret(w http.ResponseWriter, namespace, name string) { +func (s *ClusterState) deleteSecret(w http.ResponseWriter, r *http.Request, namespace, name string) { key := secretKey(namespace, name) s.mu.Lock() @@ -275,6 +304,12 @@ func (s *ClusterState) deleteSecret(w http.ResponseWriter, namespace, name strin return } + if isDryRun(r) { + writeJSON(w, http.StatusOK, sec.DeepCopy()) + + return + } + delete(s.secrets, key) s.wSecrets.publish(EventDeleted, namespace, *sec.DeepCopy()) writeJSON(w, http.StatusOK, sec.DeepCopy()) diff --git a/services/kubernetes/service.go b/services/kubernetes/service.go index 4291a21d..94c39dae 100644 --- a/services/kubernetes/service.go +++ b/services/kubernetes/service.go @@ -42,7 +42,7 @@ func (s *ClusterState) serveServices(w http.ResponseWriter, r *http.Request, rou return } - s.listServicesAllNamespaces(w) + s.listServicesAllNamespaces(w, r) return } @@ -71,7 +71,7 @@ func (s *ClusterState) serveServiceCollection(w http.ResponseWriter, r *http.Req return } - s.listServices(w, namespace) + s.listServices(w, r, namespace) case http.MethodPost: s.createService(w, r, namespace) default: @@ -97,7 +97,7 @@ func (s *ClusterState) serveServiceItem(w http.ResponseWriter, r *http.Request, case http.MethodPatch: s.patchService(w, r, namespace, name) case http.MethodDelete: - s.deleteService(w, namespace, name) + s.deleteService(w, r, namespace, name) default: writeMethodNotAllowed(w, "k8s api: service item: method not allowed: "+r.Method) } @@ -128,7 +128,7 @@ func (s *ClusterState) createService(w http.ResponseWriter, r *http.Request, nam return } - stamp(&in.ObjectMeta) + s.stamp(&in.ObjectMeta) in.TypeMeta = metav1.TypeMeta{Kind: "Service", APIVersion: "v1"} if in.Spec.Type == "" { @@ -147,12 +147,18 @@ func (s *ClusterState) createService(w http.ResponseWriter, r *http.Request, nam in.Spec.ClusterIPs = []string{in.Spec.ClusterIP} } + if isDryRun(r) { + writeJSON(w, http.StatusCreated, &in) + + return + } + svc := in s.services[key] = &svc // Auto-create the Endpoints object, then let the endpoints controller fill // its Subsets from Running Pods that match the Service selector. - ep := newEndpointsObject(namespace, svc.Name) + ep := s.newEndpointsObject(namespace, svc.Name) s.endpoints[endpointsKey(namespace, svc.Name)] = ep s.wServices.publish(EventAdded, namespace, *svc.DeepCopy()) @@ -162,24 +168,34 @@ func (s *ClusterState) createService(w http.ResponseWriter, r *http.Request, nam writeJSON(w, http.StatusCreated, &svc) } -func (s *ClusterState) listServices(w http.ResponseWriter, namespace string) { +func (s *ClusterState) listServices(w http.ResponseWriter, r *http.Request, namespace string) { s.mu.RLock() defer s.mu.RUnlock() - items := s.collectServicesLocked(namespace) + items, cont, ok := listPage(s.collectServicesLocked(namespace), w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &corev1.ServiceList{ TypeMeta: metav1.TypeMeta{Kind: "ServiceList", APIVersion: "v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } -func (s *ClusterState) listServicesAllNamespaces(w http.ResponseWriter) { +func (s *ClusterState) listServicesAllNamespaces(w http.ResponseWriter, r *http.Request) { s.mu.RLock() defer s.mu.RUnlock() - items := s.collectServicesLocked("") + items, cont, ok := listPage(s.collectServicesLocked(""), w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &corev1.ServiceList{ TypeMeta: metav1.TypeMeta{Kind: "ServiceList", APIVersion: "v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } @@ -259,6 +275,12 @@ func (s *ClusterState) updateService(w http.ResponseWriter, r *http.Request, nam in.Spec.Type = cur.Spec.Type } + if isDryRun(r) { + writeJSON(w, http.StatusOK, &in) + + return + } + svc := in s.services[key] = &svc s.wServices.publish(EventModified, namespace, *svc.DeepCopy()) @@ -287,12 +309,19 @@ func (s *ClusterState) patchService(w http.ResponseWriter, r *http.Request, name // Same ClusterIP-immutable rule as updateService. patched.Spec.ClusterIP = cur.Spec.ClusterIP patched.Spec.ClusterIPs = cur.Spec.ClusterIPs + + if isDryRun(r) { + writeJSON(w, http.StatusOK, patched) + + return + } + s.services[key] = patched s.wServices.publish(EventModified, namespace, *patched.DeepCopy()) writeJSON(w, http.StatusOK, patched) } -func (s *ClusterState) deleteService(w http.ResponseWriter, namespace, name string) { +func (s *ClusterState) deleteService(w http.ResponseWriter, r *http.Request, namespace, name string) { key := serviceKey(namespace, name) s.mu.Lock() @@ -305,6 +334,12 @@ func (s *ClusterState) deleteService(w http.ResponseWriter, namespace, name stri return } + if isDryRun(r) { + writeJSON(w, http.StatusOK, svc.DeepCopy()) + + return + } + delete(s.services, key) s.wServices.publish(EventDeleted, namespace, *svc.DeepCopy()) diff --git a/services/kubernetes/serviceaccount.go b/services/kubernetes/serviceaccount.go index 5a821b87..765d3f6c 100644 --- a/services/kubernetes/serviceaccount.go +++ b/services/kubernetes/serviceaccount.go @@ -4,7 +4,6 @@ import ( "net/http" "sort" "strings" - "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -39,7 +38,7 @@ func (s *ClusterState) serveServiceAccounts(w http.ResponseWriter, r *http.Reque return } - s.listServiceAccountsAllNamespaces(w) + s.listServiceAccountsAllNamespaces(w, r) return } @@ -68,7 +67,7 @@ func (s *ClusterState) serveServiceAccountCollection(w http.ResponseWriter, r *h return } - s.listServiceAccounts(w, namespace) + s.listServiceAccounts(w, r, namespace) case http.MethodPost: s.createServiceAccount(w, r, namespace) default: @@ -94,7 +93,7 @@ func (s *ClusterState) serveServiceAccountItem(w http.ResponseWriter, r *http.Re case http.MethodPatch: s.patchServiceAccount(w, r, namespace, name) case http.MethodDelete: - s.deleteServiceAccount(w, namespace, name) + s.deleteServiceAccount(w, r, namespace, name) default: writeMethodNotAllowed(w, "k8s api: serviceaccount item: method not allowed: "+r.Method) } @@ -126,33 +125,49 @@ func (s *ClusterState) createServiceAccount(w http.ResponseWriter, r *http.Reque return } - stamp(&in.ObjectMeta) + s.stamp(&in.ObjectMeta) in.TypeMeta = metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"} + if isDryRun(r) { + writeJSON(w, http.StatusCreated, &in) + + return + } + sa := in s.serviceAccounts[key] = &sa s.wServiceAccounts.publish(EventAdded, namespace, *sa.DeepCopy()) writeJSON(w, http.StatusCreated, &sa) } -func (s *ClusterState) listServiceAccounts(w http.ResponseWriter, namespace string) { +func (s *ClusterState) listServiceAccounts(w http.ResponseWriter, r *http.Request, namespace string) { s.mu.RLock() defer s.mu.RUnlock() - items := s.collectServiceAccountsLocked(namespace) + items, cont, ok := listPage(s.collectServiceAccountsLocked(namespace), w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &corev1.ServiceAccountList{ TypeMeta: metav1.TypeMeta{Kind: "ServiceAccountList", APIVersion: "v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } -func (s *ClusterState) listServiceAccountsAllNamespaces(w http.ResponseWriter) { +func (s *ClusterState) listServiceAccountsAllNamespaces(w http.ResponseWriter, r *http.Request) { s.mu.RLock() defer s.mu.RUnlock() - items := s.collectServiceAccountsLocked("") + items, cont, ok := listPage(s.collectServiceAccountsLocked(""), w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, &corev1.ServiceAccountList{ TypeMeta: metav1.TypeMeta{Kind: "ServiceAccountList", APIVersion: "v1"}, + ListMeta: metav1.ListMeta{Continue: cont}, Items: items, }) } @@ -220,6 +235,12 @@ func (s *ClusterState) updateServiceAccount(w http.ResponseWriter, r *http.Reque in.ResourceVersion = bumpResourceVersion(cur.ResourceVersion) in.TypeMeta = cur.TypeMeta + if isDryRun(r) { + writeJSON(w, http.StatusOK, &in) + + return + } + sa := in s.serviceAccounts[key] = &sa s.wServiceAccounts.publish(EventModified, namespace, *sa.DeepCopy()) @@ -249,12 +270,19 @@ func (s *ClusterState) patchServiceAccount(w http.ResponseWriter, r *http.Reques } patched.ResourceVersion = bumpResourceVersion(cur.ResourceVersion) + + if isDryRun(r) { + writeJSON(w, http.StatusOK, patched) + + return + } + s.serviceAccounts[key] = patched s.wServiceAccounts.publish(EventModified, namespace, *patched.DeepCopy()) writeJSON(w, http.StatusOK, patched) } -func (s *ClusterState) deleteServiceAccount(w http.ResponseWriter, namespace, name string) { +func (s *ClusterState) deleteServiceAccount(w http.ResponseWriter, r *http.Request, namespace, name string) { key := serviceAccountKey(namespace, name) s.mu.Lock() @@ -267,6 +295,12 @@ func (s *ClusterState) deleteServiceAccount(w http.ResponseWriter, namespace, na return } + if isDryRun(r) { + writeJSON(w, http.StatusOK, sa.DeepCopy()) + + return + } + delete(s.serviceAccounts, key) s.wServiceAccounts.publish(EventDeleted, namespace, *sa.DeepCopy()) writeJSON(w, http.StatusOK, sa.DeepCopy()) @@ -280,14 +314,14 @@ func serviceAccountKey(namespace, name string) string { // fields a real apiserver fills in on create. Token Secrets used to be // auto-created here (pre-1.24); Wave 2 follows current behavior and leaves // .secrets empty. -func newServiceAccountObject(namespace, name string) *corev1.ServiceAccount { +func (s *ClusterState) newServiceAccountObject(namespace, name string) *corev1.ServiceAccount { return &corev1.ServiceAccount{ TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, UID: types.UID(newUID()), - CreationTimestamp: metav1.NewTime(time.Now()), + CreationTimestamp: s.now(), ResourceVersion: "1", }, } diff --git a/services/kubernetes/ssa.go b/services/kubernetes/ssa.go new file mode 100644 index 00000000..cd48df69 --- /dev/null +++ b/services/kubernetes/ssa.go @@ -0,0 +1,582 @@ +package kubernetes + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "sort" + "strings" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// Server-side apply with field ownership. Each apply request carries a +// fieldManager; the server records which leaf fields that manager owns in +// metadata.managedFields. A later apply by a DIFFERENT manager that changes a +// field the first manager owns is a conflict (HTTP 409) unless force=true, which +// transfers ownership. A re-apply by the SAME manager that OMITS a field it +// previously owned removes that field from the object (upstream apply semantics), +// unless another manager also owns it. Plain PUT/PATCH updates record an +// Update-operation managedFields entry for their fieldManager so ownership +// reflects reality; they take/share ownership rather than conflicting (only +// Apply-vs-Apply is a 409). The one residual shortcut versus upstream SSA is +// granularity: ownership and conflict detection track leaf fields (map keys and +// whole arrays), so per-element structural merging of list items is not modeled. + +// pathSep joins path segments internally. A null byte can't appear in a JSON key +// (labels contain dots and slashes, so dot-joining would be ambiguous). +const pathSep = "\x00" + +// defaultFieldManager is used when a request omits ?fieldManager=. +const defaultFieldManager = "cloudemu" + +// managedFields operation values. +const ( + applyOperation = "Apply" + updateOperation = "Update" +) + +// ssaSkipTop are the top-level fields never tracked as owned (identity + server- +// owned metadata + status, which flows through its own subresource). +// +//nolint:gochecknoglobals // immutable lookup set. +var ssaSkipTop = map[string]bool{"apiVersion": true, "kind": true, "status": true} + +// ssaSkipMeta are metadata subfields never tracked as owned. +// +//nolint:gochecknoglobals // immutable lookup set. +var ssaSkipMeta = map[string]bool{ + "managedFields": true, "resourceVersion": true, "creationTimestamp": true, + "uid": true, "generation": true, "selfLink": true, +} + +// serverSideApply handles an apply-patch: it detects conflicts against other +// field managers, merges the applied config, and rewrites managedFields. Returns +// (merged, true) on success; on conflict or wire error it has already written +// the response and returns (nil, false). +func (s *ClusterState) serverSideApply( + w http.ResponseWriter, r *http.Request, st *registryStore, cur *unstructured.Unstructured, +) (*unstructured.Unstructured, bool) { + body, err := io.ReadAll(r.Body) + if err != nil { + writeBadRequest(w, "k8s api: read apply body: "+err.Error()) + + return nil, false + } + + applied := map[string]any{} + if err := json.Unmarshal(body, &applied); err != nil { + writeBadRequest(w, "k8s api: decode apply body: "+err.Error()) + + return nil, false + } + + manager := r.URL.Query().Get("fieldManager") + if manager == "" { + manager = defaultFieldManager + } + + force := r.URL.Query().Get("force") == "true" + appliedLeaves := ownedLeaves(applied) + prevLeaves := managerApplyLeaves(cur, manager) + + if conflicts := s.applyConflicts(cur, applied, appliedLeaves, manager); len(conflicts) > 0 && !force { + writeStatus(w, http.StatusConflict, metav1.StatusReasonConflict, + "Apply failed with conflicts: fields managed by another manager: "+strings.Join(conflicts, ", ")) + + return nil, false + } + + merged, mok := decodeUnstructuredMap(w, mergeRFC7396(cur.Object, applied)) + if !mok { + return nil, false + } + + setManagedFields(merged, updateManagedFields(cur, manager, appliedLeaves, st.def.apiVersion(), s.now())) + // Upstream apply removes fields this manager previously owned but now omits, + // unless another manager still owns them. + removeDroppedLeaves(merged, diffLeaves(prevLeaves, appliedLeaves)) + + return merged, true +} + +// managerApplyLeaves returns the leaves currently owned by manager's Apply entry. +func managerApplyLeaves(obj *unstructured.Unstructured, manager string) map[string]bool { + out := map[string]bool{} + + entries, _, _ := unstructured.NestedSlice(obj.Object, "metadata", "managedFields") + for _, e := range entries { + em, ok := e.(map[string]any) + if !ok { + continue + } + + mgr, _, _ := unstructured.NestedString(em, "manager") + if op, _, _ := unstructured.NestedString(em, "operation"); mgr != manager || op != applyOperation { + continue + } + + for leaf := range leavesOfEntry(em) { + out[leaf] = true + } + } + + return out +} + +// diffLeaves returns leaves present in prev but not in keep. +func diffLeaves(prev, keep map[string]bool) map[string]bool { + out := map[string]bool{} + + for leaf := range prev { + if !keep[leaf] { + out[leaf] = true + } + } + + return out +} + +// removeDroppedLeaves deletes each dropped leaf from obj unless some managedFields +// entry still owns it (shared ownership survives an omit). +func removeDroppedLeaves(obj *unstructured.Unstructured, dropped map[string]bool) { + if len(dropped) == 0 { + return + } + + owners := ownerByLeaf(obj) + + for leaf := range dropped { + if _, stillOwned := owners[leaf]; stillOwned { + continue + } + + unstructured.RemoveNestedField(obj.Object, strings.Split(leaf, pathSep)...) + } +} + +// applyConflicts returns the human-readable paths the applied config would +// change that are currently owned by a different manager (value actually +// differs). An empty result means no conflict. +func (*ClusterState) applyConflicts( + cur *unstructured.Unstructured, applied map[string]any, appliedLeaves map[string]bool, manager string, +) []string { + owners := ownerByLeaf(cur) + + var out []string + + for leaf := range appliedLeaves { + other, ok := owners[leaf] + if !ok || other == manager { + continue + } + + segs := strings.Split(leaf, pathSep) + curVal, _, _ := unstructured.NestedFieldNoCopy(cur.Object, segs...) + newVal, _, _ := unstructured.NestedFieldNoCopy(applied, segs...) + + if !jsonEqual(curVal, newVal) { + out = append(out, strings.Join(segs, ".")+" (owned by "+other+")") + } + } + + sort.Strings(out) + + return out +} + +// ownedLeaves returns the set of owned leaf paths in an applied config, as +// pathSep-joined segment keys, skipping identity/server-owned fields. +func ownedLeaves(applied map[string]any) map[string]bool { + out := map[string]bool{} + collectLeaves(applied, nil, out) + + return out +} + +func collectLeaves(node any, prefix []string, out map[string]bool) { + m, ok := node.(map[string]any) + if !ok { + if len(prefix) > 0 { + out[strings.Join(prefix, pathSep)] = true + } + + return + } + + for k, v := range m { + if skipLeaf(prefix, k) { + continue + } + + collectLeaves(v, append(append([]string{}, prefix...), k), out) + } +} + +// skipLeaf reports whether a (prefix, key) should not be tracked as an owned +// field — identity, server-owned metadata, and status. +func skipLeaf(prefix []string, key string) bool { + if len(prefix) == 0 { + return ssaSkipTop[key] + } + + if len(prefix) == 1 && prefix[0] == "metadata" { + return ssaSkipMeta[key] + } + + return false +} + +// ownerByLeaf inverts the object's managedFields into leaf → manager. +func ownerByLeaf(obj *unstructured.Unstructured) map[string]string { + out := map[string]string{} + + entries, _, _ := unstructured.NestedSlice(obj.Object, "metadata", "managedFields") + for _, e := range entries { + em, ok := e.(map[string]any) + if !ok { + continue + } + + mgr, _, _ := unstructured.NestedString(em, "manager") + fields, _, _ := unstructured.NestedMap(em, "fieldsV1") + + for _, leaf := range leavesFromFieldsV1(fields, nil) { + out[leaf] = mgr + } + } + + return out +} + +// updateManagedFields returns the new managedFields slice: this manager owns +// appliedLeaves, and those leaves are removed from every other manager (an apply +// transfers ownership). +func updateManagedFields( + cur *unstructured.Unstructured, manager string, appliedLeaves map[string]bool, apiVersion string, now metav1.Time, +) []any { + existing, _, _ := unstructured.NestedSlice(cur.Object, "metadata", "managedFields") + + out := make([]any, 0, len(existing)+1) + + for _, e := range existing { + em, ok := e.(map[string]any) + if !ok { + continue + } + + if mgr, _, _ := unstructured.NestedString(em, "manager"); mgr == manager { + continue // replaced below + } + + if kept := subtractLeaves(em, appliedLeaves); kept != nil { + out = append(out, kept) + } + } + + return append(out, managedFieldsEntry(manager, appliedLeaves, apiVersion, now)) +} + +// subtractLeaves rebuilds a managedFields entry with the given leaves removed, +// or nil if the manager no longer owns anything. +func subtractLeaves(entry map[string]any, remove map[string]bool) map[string]any { + fields, _, _ := unstructured.NestedMap(entry, "fieldsV1") + kept := map[string]bool{} + + for _, leaf := range leavesFromFieldsV1(fields, nil) { + if !remove[leaf] { + kept[leaf] = true + } + } + + if len(kept) == 0 { + return nil + } + + entry["fieldsV1"] = fieldsV1FromLeaves(kept) + + return entry +} + +func managedFieldsEntry(manager string, leaves map[string]bool, apiVersion string, now metav1.Time) map[string]any { + return map[string]any{ + "manager": manager, + "operation": applyOperation, + "apiVersion": apiVersion, + "time": now.Format(time.RFC3339), + "fieldsType": "FieldsV1", + "fieldsV1": fieldsV1FromLeaves(leaves), + } +} + +// leavesOfEntry returns the leaf set recorded in a managedFields entry's fieldsV1. +func leavesOfEntry(em map[string]any) map[string]bool { + fields, _, _ := unstructured.NestedMap(em, "fieldsV1") + out := map[string]bool{} + + for _, leaf := range leavesFromFieldsV1(fields, nil) { + out[leaf] = true + } + + return out +} + +// unionLeaves returns the union of two leaf sets. +func unionLeaves(a, b map[string]bool) map[string]bool { + out := make(map[string]bool, len(a)+len(b)) + + for leaf := range a { + out[leaf] = true + } + + for leaf := range b { + out[leaf] = true + } + + return out +} + +// updateFieldManager resolves the fieldManager for a plain PUT/PATCH: the query +// param when present, else the leading component of the User-Agent (matching real +// k8s deriving the manager from the client binary), else the default. +func updateFieldManager(r *http.Request) string { + if m := r.URL.Query().Get("fieldManager"); m != "" { + return m + } + + if ua := r.UserAgent(); ua != "" { + if i := strings.IndexAny(ua, "/ "); i > 0 { + return ua[:i] + } + + return ua + } + + return defaultFieldManager +} + +// stampUpdateOwnership records an Update-operation managedFields entry for the +// leaves an update set, merged onto base (the object's prior managedFields). It +// takes/shares ownership without removing other managers' entries. +func (s *ClusterState) stampUpdateOwnership( + obj *unstructured.Unstructured, base []any, manager, apiVersion string, leaves map[string]bool, +) { + if len(leaves) == 0 { + setManagedFields(obj, base) + + return + } + + setManagedFields(obj, upsertUpdateEntry(base, manager, leaves, apiVersion, s.now())) +} + +// upsertUpdateEntry merges leaves into manager's existing Update entry, or appends +// a new one; all other entries are preserved unchanged. +func upsertUpdateEntry(existing []any, manager string, leaves map[string]bool, apiVersion string, now metav1.Time) []any { + out := make([]any, 0, len(existing)+1) + merged := false + + for _, e := range existing { + em, ok := e.(map[string]any) + if !ok { + continue + } + + mgr, _, _ := unstructured.NestedString(em, "manager") + if op, _, _ := unstructured.NestedString(em, "operation"); mgr == manager && op == updateOperation { + out = append(out, updateEntry(manager, unionLeaves(leavesOfEntry(em), leaves), apiVersion, now)) + merged = true + + continue + } + + out = append(out, em) + } + + if !merged { + out = append(out, updateEntry(manager, leaves, apiVersion, now)) + } + + return out +} + +// updateEntry builds an Update-operation managedFields entry. +func updateEntry(manager string, leaves map[string]bool, apiVersion string, now metav1.Time) map[string]any { + e := managedFieldsEntry(manager, leaves, apiVersion, now) + e["operation"] = updateOperation + + return e +} + +// changedLeaves returns the owned leaves whose value differs between cur and +// patched — the fields a patch actually set. +func changedLeaves(cur, patched map[string]any) map[string]bool { + out := ownedLeaves(patched) + + for leaf := range out { + segs := strings.Split(leaf, pathSep) + a, _, _ := unstructured.NestedFieldNoCopy(cur, segs...) + b, _, _ := unstructured.NestedFieldNoCopy(patched, segs...) + + if jsonEqual(a, b) { + delete(out, leaf) + } + } + + return out +} + +// managedFieldsOf returns an object's current managedFields slice. +func managedFieldsOf(obj *unstructured.Unstructured) []any { + entries, _, _ := unstructured.NestedSlice(obj.Object, "metadata", "managedFields") + + return entries +} + +// objectMap marshals a typed object to a generic JSON map for leaf computation. +func objectMap(v any) map[string]any { + b, err := json.Marshal(v) + if err != nil { + return nil + } + + m := map[string]any{} + if json.Unmarshal(b, &m) != nil { + return nil + } + + return m +} + +// typedEntryLeaves returns the leaf set recorded in a typed managedFields entry. +func typedEntryLeaves(e *metav1.ManagedFieldsEntry) map[string]bool { + out := map[string]bool{} + if e.FieldsV1 == nil { + return out + } + + fields := map[string]any{} + if json.Unmarshal(e.FieldsV1.Raw, &fields) != nil { + return out + } + + for _, leaf := range leavesFromFieldsV1(fields, nil) { + out[leaf] = true + } + + return out +} + +// typedUpdateEntry builds an Update-operation managedFields entry for a typed object. +func typedUpdateEntry(manager string, leaves map[string]bool, apiVersion string, now metav1.Time) metav1.ManagedFieldsEntry { + raw, _ := json.Marshal(fieldsV1FromLeaves(leaves)) + stamp := now + + return metav1.ManagedFieldsEntry{ + Manager: manager, + Operation: metav1.ManagedFieldsOperationUpdate, + APIVersion: apiVersion, + Time: &stamp, + FieldsType: "FieldsV1", + FieldsV1: &metav1.FieldsV1{Raw: raw}, + } +} + +// upsertTypedUpdateEntry merges leaves into manager's existing Update entry or +// appends a new one, returning a fresh slice (never mutates existing). +func upsertTypedUpdateEntry( + existing []metav1.ManagedFieldsEntry, manager string, leaves map[string]bool, apiVersion string, now metav1.Time, +) []metav1.ManagedFieldsEntry { + if len(leaves) == 0 { + return append([]metav1.ManagedFieldsEntry(nil), existing...) + } + + out := make([]metav1.ManagedFieldsEntry, 0, len(existing)+1) + merged := false + + for i := range existing { + e := &existing[i] + if e.Manager == manager && e.Operation == metav1.ManagedFieldsOperationUpdate { + out = append(out, typedUpdateEntry(manager, unionLeaves(typedEntryLeaves(e), leaves), apiVersion, now)) + merged = true + + continue + } + + out = append(out, *e) + } + + if !merged { + out = append(out, typedUpdateEntry(manager, leaves, apiVersion, now)) + } + + return out +} + +func setManagedFields(obj *unstructured.Unstructured, entries []any) { + _ = unstructured.SetNestedSlice(obj.Object, entries, "metadata", "managedFields") +} + +// fieldsV1FromLeaves builds the upstream f:-prefixed nested form from a leaf set. +func fieldsV1FromLeaves(leaves map[string]bool) map[string]any { + root := map[string]any{} + + for leaf := range leaves { + cur := root + + for _, seg := range strings.Split(leaf, pathSep) { + key := "f:" + seg + + next, ok := cur[key].(map[string]any) + if !ok { + next = map[string]any{} + cur[key] = next + } + + cur = next + } + } + + return root +} + +// leavesFromFieldsV1 inverts fieldsV1FromLeaves back to pathSep-joined leaves. +func leavesFromFieldsV1(fields map[string]any, prefix []string) []string { + var out []string + + for k, v := range fields { + seg := strings.TrimPrefix(k, "f:") + child, ok := v.(map[string]any) + + if !ok || len(child) == 0 { + out = append(out, strings.Join(append(append([]string{}, prefix...), seg), pathSep)) + + continue + } + + out = append(out, leavesFromFieldsV1(child, append(append([]string{}, prefix...), seg))...) + } + + return out +} + +func jsonEqual(a, b any) bool { + ab, _ := json.Marshal(a) + bb, _ := json.Marshal(b) + + return bytes.Equal(ab, bb) +} + +func decodeUnstructuredMap(w http.ResponseWriter, merged any) (*unstructured.Unstructured, bool) { + m, ok := merged.(map[string]any) + if !ok { + writeBadRequest(w, "k8s api: apply produced a non-object result") + + return nil, false + } + + return &unstructured.Unstructured{Object: m}, true +} diff --git a/services/kubernetes/ssa_fieldownership_test.go b/services/kubernetes/ssa_fieldownership_test.go new file mode 100644 index 00000000..0e51f6b7 --- /dev/null +++ b/services/kubernetes/ssa_fieldownership_test.go @@ -0,0 +1,293 @@ +// Tests for SSA field-ownership fixes (Review Finding 9): apply removes fields a +// manager previously owned but now omits, and plain PUT/PATCH updates record an +// Update-operation managedFields entry (co-ownership, never a false 409). + +package kubernetes_test + +import ( + "bytes" + "io" + "net/http" + "testing" +) + +// doWithHeaders issues a request with an explicit content-type (used for the +// non-default merge-patch content type the apply harness doesn't cover). +func doWithHeaders(t *testing.T, method, url, contentType string, body []byte) *http.Response { + t.Helper() + + req, err := http.NewRequest(method, url, bytes.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + + return resp +} + +// managedFieldsOf extracts metadata.managedFields as a slice of entry maps. +func managedFieldsEntries(t *testing.T, m map[string]any) []map[string]any { + t.Helper() + + meta, _ := m["metadata"].(map[string]any) + raw, _ := meta["managedFields"].([]any) + + out := make([]map[string]any, 0, len(raw)) + + for _, e := range raw { + if em, ok := e.(map[string]any); ok { + out = append(out, em) + } + } + + return out +} + +// findManagedEntry returns the entry for (manager, operation), or nil. +func findManagedEntry(entries []map[string]any, manager, operation string) map[string]any { + for _, e := range entries { + if e["manager"] == manager && e["operation"] == operation { + return e + } + } + + return nil +} + +// entryHasLeaf reports whether an entry's fieldsV1 records the f:-prefixed path. +func entryHasLeaf(entry map[string]any, segs ...string) bool { + cur, _ := entry["fieldsV1"].(map[string]any) + + for _, s := range segs { + if cur == nil { + return false + } + + next, ok := cur["f:"+s].(map[string]any) + if !ok { + return false + } + + cur = next + } + + return cur != nil +} + +// seedNetworkPolicy creates an empty NetworkPolicy and returns its item URL. +func seedNetworkPolicy(t *testing.T, base string) string { + t.Helper() + + npURL := base + "/apis/networking.k8s.io/v1/namespaces/default/networkpolicies" + seed := mustJSON(t, map[string]any{ + "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": map[string]any{"name": "np"}, + "spec": map[string]any{"podSelector": map[string]any{}}, + }) + + c := do(t, http.MethodPost, npURL, seed) + if c.StatusCode != http.StatusCreated { + c.Body.Close() + t.Fatalf("seed NetworkPolicy: got %d, want 201", c.StatusCode) + } + + c.Body.Close() + + return npURL + "/np" +} + +func npBody(a, b any) map[string]any { + spec := map[string]any{"a": a} + if b != nil { + spec["b"] = b + } + + return map[string]any{ + "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": map[string]any{"name": "np"}, "spec": spec, + } +} + +func TestSSA_ApplyRemovesOmittedOwnedField(t *testing.T) { + base, done := newFixture(t) + defer done() + + itemURL := seedNetworkPolicy(t, base) + + // mgr-1 owns spec.a and spec.b. + r1 := apply(t, itemURL, "mgr-1", false, npBody(float64(1), float64(2))) + if r1.StatusCode != http.StatusOK { + r1.Body.Close() + t.Fatalf("first apply: got %d, want 200", r1.StatusCode) + } + r1.Body.Close() + + // mgr-1 re-applies WITHOUT spec.b — real SSA removes the omitted owned field. + r2 := apply(t, itemURL, "mgr-1", false, npBody(float64(1), nil)) + if r2.StatusCode != http.StatusOK { + r2.Body.Close() + t.Fatalf("re-apply: got %d, want 200", r2.StatusCode) + } + + obj := decodeMap(t, r2.Body) + + spec, _ := obj["spec"].(map[string]any) + if _, ok := spec["b"]; ok { + t.Fatalf("spec.b should be removed after omit; spec=%v", spec) + } + + if _, ok := spec["a"]; !ok { + t.Fatalf("spec.a should remain; spec=%v", spec) + } + + entry := findManagedEntry(managedFieldsEntries(t, obj), "mgr-1", "Apply") + if entry == nil { + t.Fatal("mgr-1 Apply entry missing") + } + + if entryHasLeaf(entry, "spec", "b") { + t.Fatal("mgr-1 should no longer own spec.b") + } + + if !entryHasLeaf(entry, "spec", "a") { + t.Fatal("mgr-1 should still own spec.a") + } +} + +func TestSSA_ApplySharedFieldNotRemoved(t *testing.T) { + base, done := newFixture(t) + defer done() + + itemURL := seedNetworkPolicy(t, base) + + // mgr-1 owns spec.a and spec.b via Apply. + r1 := apply(t, itemURL, "mgr-1", false, npBody(float64(1), float64(2))) + r1.Body.Close() + + // A plain PUT co-owns spec.b (Update entry) without stripping mgr-1's Apply. + p := do(t, http.MethodPut, itemURL+"?fieldManager=kubectl-update", mustJSON(t, npBody(float64(1), float64(2)))) + if p.StatusCode != http.StatusOK { + p.Body.Close() + t.Fatalf("PUT: got %d, want 200", p.StatusCode) + } + p.Body.Close() + + // mgr-1 re-applies WITHOUT spec.b. Because kubectl-update still owns b, it + // must NOT be deleted. + r2 := apply(t, itemURL, "mgr-1", false, npBody(float64(1), nil)) + if r2.StatusCode != http.StatusOK { + r2.Body.Close() + t.Fatalf("re-apply: got %d, want 200", r2.StatusCode) + } + + obj := decodeMap(t, r2.Body) + + spec, _ := obj["spec"].(map[string]any) + if _, ok := spec["b"]; !ok { + t.Fatalf("shared spec.b must survive mgr-1's omit; spec=%v", spec) + } +} + +func TestUpdate_PUTRegistersUpdateManager(t *testing.T) { + base, done := newFixture(t) + defer done() + + itemURL := seedNetworkPolicy(t, base) + + a := apply(t, itemURL, "mgr-1", false, npBody(float64(1), nil)) + a.Body.Close() + + p := do(t, http.MethodPut, itemURL+"?fieldManager=kubectl-update", mustJSON(t, npBody(float64(1), nil))) + if p.StatusCode != http.StatusOK { + p.Body.Close() + t.Fatalf("PUT: got %d, want 200", p.StatusCode) + } + + obj := decodeMap(t, p.Body) + + if findManagedEntry(managedFieldsEntries(t, obj), "kubectl-update", "Update") == nil { + t.Fatal("PUT did not record an Update managedFields entry for kubectl-update") + } + + // mgr-1's Apply ownership must survive the co-owning PUT. + if findManagedEntry(managedFieldsEntries(t, obj), "mgr-1", "Apply") == nil { + t.Fatal("mgr-1 Apply entry should be preserved across a PUT") + } +} + +func TestUpdate_NoFalse409OverApplyOwnedField(t *testing.T) { + base, done := newFixture(t) + defer done() + + itemURL := seedNetworkPolicy(t, base) + + a := apply(t, itemURL, "mgr-1", false, npBody(float64(1), nil)) + a.Body.Close() + + // PUT overwriting mgr-1's Apply-owned spec.a must succeed (no conflict). + p := do(t, http.MethodPut, itemURL+"?fieldManager=kubectl-update", mustJSON(t, npBody(float64(5), nil))) + if p.StatusCode != http.StatusOK { + body, _ := io.ReadAll(p.Body) + p.Body.Close() + t.Fatalf("PUT over apply-owned field: got %d, want 200 (%s)", p.StatusCode, body) + } + + obj := decodeMap(t, p.Body) + if spec, _ := obj["spec"].(map[string]any); spec["a"] != float64(5) { + t.Fatalf("PUT should overwrite spec.a; spec=%v", obj["spec"]) + } + + // A merge PATCH over the same field also succeeds and stamps an Update entry. + pj := doWithHeaders(t, http.MethodPatch, itemURL+"?fieldManager=kubectl-patch", + "application/merge-patch+json", mustJSON(t, map[string]any{"spec": map[string]any{"a": float64(9)}})) + if pj.StatusCode != http.StatusOK { + body, _ := io.ReadAll(pj.Body) + pj.Body.Close() + t.Fatalf("merge PATCH over apply-owned field: got %d, want 200 (%s)", pj.StatusCode, body) + } + + obj = decodeMap(t, pj.Body) + if findManagedEntry(managedFieldsEntries(t, obj), "kubectl-patch", "Update") == nil { + t.Fatal("PATCH did not record an Update managedFields entry") + } +} + +func TestUpdate_PodPUTRegistersUpdateManager(t *testing.T) { + base, done := newFixture(t) + defer done() + + podURL := base + "/api/v1/namespaces/default/pods" + pod := map[string]any{ + "apiVersion": "v1", "kind": "Pod", + "metadata": map[string]any{"name": "web"}, + "spec": map[string]any{ + "containers": []any{map[string]any{"name": "app", "image": "nginx:1.27"}}, + }, + } + + c := do(t, http.MethodPost, podURL, mustJSON(t, pod)) + if c.StatusCode != http.StatusCreated { + c.Body.Close() + t.Fatalf("create pod: got %d, want 201", c.StatusCode) + } + c.Body.Close() + + pod["spec"].(map[string]any)["containers"].([]any)[0].(map[string]any)["image"] = "nginx:1.28" + + p := do(t, http.MethodPut, podURL+"/web?fieldManager=kubectl-update", mustJSON(t, pod)) + if p.StatusCode != http.StatusOK { + p.Body.Close() + t.Fatalf("pod PUT: got %d, want 200", p.StatusCode) + } + + obj := decodeMap(t, p.Body) + if findManagedEntry(managedFieldsEntries(t, obj), "kubectl-update", "Update") == nil { + t.Fatal("pod PUT did not record an Update managedFields entry") + } +} diff --git a/services/kubernetes/state.go b/services/kubernetes/state.go index b04895c2..fc457eea 100644 --- a/services/kubernetes/state.go +++ b/services/kubernetes/state.go @@ -2,11 +2,15 @@ package kubernetes import ( "net/http" + "strings" "sync" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stackshy/cloudemu/v2/config" ) // ClusterState is the in-memory backing store for one Kubernetes cluster's @@ -21,6 +25,11 @@ import ( type ClusterState struct { mu sync.RWMutex + // clock sources every timestamp the data plane stamps (creationTimestamp, + // pod start/condition times). A FakeClock makes all of them deterministic; + // defaults to config.RealClock. + clock config.Clock + // namespaces is cluster-scoped — keyed by namespace name. namespaces map[string]*corev1.Namespace @@ -66,6 +75,12 @@ type ClusterState struct { // hand-written handler (ReplicaSet, StatefulSet, DaemonSet, …). reg *registry + // admissionEnabled and admissionClient configure the opt-in admission + // webhook chain (see APIServer.SetAdmissionEnabled and admission.go). + // Set once at registration; admissionClient is never nil. + admissionEnabled bool + admissionClient *http.Client + // Per-resource Watch broadcasters. Handlers publish on Create/Update/ // Patch/Delete; ?watch=true requests subscribe via streamWatch. wNamespaces *broadcaster @@ -88,8 +103,17 @@ const firstClusterIPOffset uint32 = 1 // namespaces (default, kube-system, kube-public) and a "default" // ServiceAccount in each, matching the bootstrap state of a fresh real // cluster. -func newClusterState() *ClusterState { +func newClusterState(clock config.Clock, admissionEnabled bool, admissionClient *http.Client) *ClusterState { + if clock == nil { + clock = config.RealClock{} + } + + if admissionClient == nil { + admissionClient = &http.Client{Timeout: defaultAdmissionTimeout} + } + s := &ClusterState{ + clock: clock, namespaces: make(map[string]*corev1.Namespace), configMaps: make(map[string]*corev1.ConfigMap), pods: make(map[string]*corev1.Pod), @@ -102,6 +126,8 @@ func newClusterState() *ClusterState { nextClusterIP: firstClusterIPOffset, nextPodIP: 1, reg: newRegistry(registeredResources()), + admissionEnabled: admissionEnabled, + admissionClient: admissionClient, wNamespaces: newBroadcaster(), wConfigMaps: newBroadcaster(), wPods: newBroadcaster(), @@ -113,11 +139,11 @@ func newClusterState() *ClusterState { } for _, name := range []string{"default", "kube-system", "kube-public"} { - s.namespaces[name] = newNamespaceObject(name) + s.namespaces[name] = s.newNamespaceObject(name) // Real apiserver auto-creates a "default" ServiceAccount in every // namespace. We do the same so `kubectl get sa default` works in // the bootstrap namespaces. - sa := newServiceAccountObject(name, "default") + sa := s.newServiceAccountObject(name, "default") s.serviceAccounts[serviceAccountKey(name, "default")] = sa } @@ -125,7 +151,7 @@ func newClusterState() *ClusterState { // scheduled onto (spec.nodeName=cloudemu-node-0). Without it `kubectl get // nodes` is empty on a fresh cluster and Pods/DaemonSets reference a Node // object that doesn't exist. - if store := s.reg.stores[regKey("", "v1", "nodes")]; store != nil { + if store := s.reg.getStore("", "v1", "nodes"); store != nil { node := newNodeObject() store.items[objKey("", node.GetName())] = node } @@ -133,6 +159,13 @@ func newClusterState() *ClusterState { return s } +// now returns the current time from the cluster's clock as a metav1.Time. Every +// data-plane timestamp flows through here so a FakeClock renders them all +// deterministic for tests. +func (s *ClusterState) now() metav1.Time { + return metav1.NewTime(s.clock.Now()) +} + // ServeHTTP dispatches a Kubernetes REST request into the per-resource // handlers. The request's URL has already been stripped of the /k8s/ // prefix by APIServer.ServeHTTP, so r.URL.Path here starts with /api/v1/... @@ -144,6 +177,23 @@ func (s *ClusterState) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // metrics.k8s.io is an aggregated API, not a registry-backed kind — it has + // no persisted objects, so it can't go through parseRoute/serveRegistry. + if strings.HasPrefix(r.URL.Path, metricsAPIPrefix) { + s.serveMetrics(w, r) + + return + } + + // SubjectAccessReview is a POST-only, non-persisted "review" API — it has + // no registry store and no Route shape (parseRoute assumes a resource + // collection/item), so it's dispatched here before route parsing. + if r.Method == http.MethodPost && r.URL.Path == pathSubjectAccessReviews { + s.serveSubjectAccessReview(w, r) + + return + } + route := parseRoute(r.URL.Path) if route == nil { writeNotFound(w, "k8s api: unrecognized path "+r.URL.Path) @@ -181,7 +231,7 @@ func (s *ClusterState) dispatchResource(w http.ResponseWriter, r *http.Request, s.serveNamespaces(w, r, route) case "configmaps": s.serveConfigMaps(w, r, route) - case "pods": + case resourcePods: s.servePods(w, r, route) case "secrets": s.serveSecrets(w, r, route) diff --git a/services/kubernetes/subresource.go b/services/kubernetes/subresource.go index eff68ff6..33c41c40 100644 --- a/services/kubernetes/subresource.go +++ b/services/kubernetes/subresource.go @@ -15,6 +15,12 @@ import ( // the typed Deployment exposes /scale and /status; anything else is a 404, // matching a real apiserver's response for a nonexistent subresource. func (s *ClusterState) serveSubresource(w http.ResponseWriter, r *http.Request, route *Route) { + if route.APIGroup == "" && route.Resource == resourcePods { + s.servePodSubresource(w, r, route) + + return + } + if route.APIGroup == apiGroupApps && route.Resource == resourceDeployments { switch route.Subresource { case subresourceScale: diff --git a/services/kubernetes/watch.go b/services/kubernetes/watch.go index 07c9ef09..42be172a 100644 --- a/services/kubernetes/watch.go +++ b/services/kubernetes/watch.go @@ -21,7 +21,41 @@ func serveWatch[T any]( sub := b.subscribe(namespace) items := collect() s.mu.RUnlock() - streamWatch(r.Context(), w, sub, items, keep) + streamWatch(r.Context(), w, sub, items, keep, watchOpts{ + resume: watchResume(r), + bookmarks: watchBookmarksEnabled(r), + }) +} + +// watchOpts carries the per-request watch behaviors parsed from the query. +type watchOpts struct { + // resume is true when the client passed resourceVersion>0 — it already has + // the current state, so the initial full-snapshot replay is skipped and only + // subsequent events are streamed. (There is no watch-cache history, so events + // strictly between the client's RV and watch establishment are not backfilled + // — a documented emulation simplification; a client that needs a guarantee + // relists.) + resume bool + // bookmarks is true when the client passed allowWatchBookmarks=true. + bookmarks bool + // bookmarkObj, when non-nil and bookmarks is set, is emitted once after the + // initial sync as a BOOKMARK event carrying the current resourceVersion, so + // the client can resume from it without a relist. Only registry-backed kinds + // (which track a store resourceVersion) supply one. + bookmarkObj any +} + +// watchResume reports whether the request is resuming from a known +// resourceVersion (anything other than absent or "0"). +func watchResume(r *http.Request) bool { + rv := r.URL.Query().Get("resourceVersion") + + return rv != "" && rv != "0" +} + +// watchBookmarksEnabled reports whether the client opted into BOOKMARK events. +func watchBookmarksEnabled(r *http.Request) bool { + return r.URL.Query().Get("allowWatchBookmarks") == watchQueryValue } // parseListSelectors extracts the labelSelector and fieldSelector from a list @@ -69,6 +103,10 @@ const ( // EventError carries a Status object (e.g. 410 Gone) that tells a client-go // reflector to relist — used when a slow watcher overflowed its buffer. EventError = "ERROR" + // EventBookmark carries an object holding only the latest resourceVersion, so + // a client that opted in (allowWatchBookmarks=true) can resume from it after a + // disconnect without a full relist. + EventBookmark = "BOOKMARK" ) // expiredWatchStatus is the 410 Gone a real apiserver sends when a watch has @@ -221,6 +259,7 @@ func streamWatch[T any]( sub *subscriber, initial []T, keep func(T) bool, + opts watchOpts, ) { defer close(sub.done) @@ -238,12 +277,24 @@ func streamWatch[T any]( enc := json.NewEncoder(w) - for _, item := range initial { - if keep != nil && !keep(item) { - continue + // A resuming watch (resourceVersion>0) already holds the current state, so + // the full ADDED replay is skipped — only subsequent events are streamed. + if !opts.resume { + for _, item := range initial { + if keep != nil && !keep(item) { + continue + } + + if !encodeWatchEvent(enc, flusher, watchEvent{Type: EventAdded, Object: item}) { + return + } } + } - if !encodeWatchEvent(enc, flusher, watchEvent{Type: EventAdded, Object: item}) { + // Emit a single post-sync BOOKMARK so an opted-in client learns the current + // resourceVersion to resume from. Bypasses keep (a bookmark is not a T). + if opts.bookmarks && opts.bookmarkObj != nil { + if !encodeWatchEvent(enc, flusher, watchEvent{Type: EventBookmark, Object: opts.bookmarkObj}) { return } } diff --git a/services/kubernetes/watch_selector_test.go b/services/kubernetes/watch_selector_test.go index 164607e5..d895b653 100644 --- a/services/kubernetes/watch_selector_test.go +++ b/services/kubernetes/watch_selector_test.go @@ -26,7 +26,7 @@ func TestWatch_OverflowEmits410Gone(t *testing.T) { } rec := httptest.NewRecorder() - streamWatch[corev1.Pod](context.Background(), rec, sub, nil, nil) + streamWatch[corev1.Pod](context.Background(), rec, sub, nil, nil, watchOpts{}) body := rec.Body.String() if !strings.Contains(body, `"type":"ERROR"`) || !strings.Contains(body, "410") { diff --git a/services/kubernetes/watch_test.go b/services/kubernetes/watch_test.go index 9a0f62bc..335523b3 100644 --- a/services/kubernetes/watch_test.go +++ b/services/kubernetes/watch_test.go @@ -307,7 +307,7 @@ func TestStreamWatch_NoFlusher500s(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() - streamWatch(ctx, w, sub, []string{"x"}, nil) + streamWatch(ctx, w, sub, []string{"x"}, nil, watchOpts{}) if rec.Code != 500 { t.Fatalf("status: got %d, want 500", rec.Code) @@ -326,7 +326,7 @@ func TestStreamWatch_EncodeErrorReturns(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() - streamWatch(ctx, w, sub, []string{"item-1"}, nil) + streamWatch(ctx, w, sub, []string{"item-1"}, nil, watchOpts{}) // Defensive: the function should have returned before the deadline, // proving it bails on the encode error rather than spinning. @@ -351,7 +351,7 @@ func TestStreamWatch_InitialSnapshotAndLiveEvents(t *testing.T) { done := make(chan struct{}) go func() { - streamWatch(ctx, rec, sub, []string{"seed-1", "seed-2"}, nil) + streamWatch(ctx, rec, sub, []string{"seed-1", "seed-2"}, nil, watchOpts{}) close(done) }() diff --git a/services/monitoring/driver/driver.go b/services/monitoring/driver/driver.go index 27b2f4b9..fea73a79 100644 --- a/services/monitoring/driver/driver.go +++ b/services/monitoring/driver/driver.go @@ -6,6 +6,14 @@ import ( "time" ) +// MetricIdentifier names one metric by namespace + name. It's used by the +// AWS-local detailed metric listing that backs a namespace-less ListMetrics +// ("list all") so each returned metric keeps its real namespace. +type MetricIdentifier struct { + Namespace string + MetricName string +} + // MetricDatum is a single metric data point. type MetricDatum struct { Namespace string diff --git a/services/networkfirewall/driver/driver.go b/services/networkfirewall/driver/driver.go new file mode 100644 index 00000000..70d2527d --- /dev/null +++ b/services/networkfirewall/driver/driver.go @@ -0,0 +1,99 @@ +// Package driver defines the portable interface for AWS Network Firewall, a +// managed stateful/stateless firewall. It is a distinct service (its own +// AWS JSON API), separate from the EC2 VPC networking surface. +package driver + +import "context" + +// Firewall is a Network Firewall attached to a VPC across subnets. +type Firewall struct { + Name string + ARN string + PolicyARN string + VPCID string + SubnetIDs []string + Description string + DeleteProtection bool + Status string + Tags map[string]string +} + +// CreateFirewallConfig is the input to CreateFirewall. +type CreateFirewallConfig struct { + Name string + PolicyARN string + VPCID string + SubnetIDs []string + Description string + DeleteProtection bool + Tags map[string]string +} + +// FirewallPolicy groups stateless/stateful rule-group references + default actions. +type FirewallPolicy struct { + Name string + ARN string + ID string + Description string + StatelessDefaultActions []string + StatelessFragmentDefaultActions []string + Tags map[string]string +} + +// CreateFirewallPolicyConfig is the input to CreateFirewallPolicy. +type CreateFirewallPolicyConfig struct { + Name string + Description string + StatelessDefaultActions []string + StatelessFragmentDefaultActions []string + Tags map[string]string +} + +// RuleGroup is a reusable collection of stateful or stateless rules. +type RuleGroup struct { + Name string + ARN string + ID string + Type string // STATEFUL | STATELESS + Capacity int + Description string + Tags map[string]string +} + +// CreateRuleGroupConfig is the input to CreateRuleGroup. +type CreateRuleGroupConfig struct { + Name string + Type string + Capacity int + Description string + Tags map[string]string +} + +// NetworkFirewall is the AWS Network Firewall control plane. +// +//nolint:interfacebloat // mirrors the network-firewall API surface. +type NetworkFirewall interface { + CreateFirewall(ctx context.Context, cfg CreateFirewallConfig) (*Firewall, error) + DescribeFirewall(ctx context.Context, name, arn string) (*Firewall, error) + DeleteFirewall(ctx context.Context, name, arn string) (*Firewall, error) + ListFirewalls(ctx context.Context) ([]Firewall, error) + + CreateFirewallPolicy(ctx context.Context, cfg CreateFirewallPolicyConfig) (*FirewallPolicy, error) + DescribeFirewallPolicy(ctx context.Context, name, arn string) (*FirewallPolicy, error) + DeleteFirewallPolicy(ctx context.Context, name, arn string) (*FirewallPolicy, error) + ListFirewallPolicies(ctx context.Context) ([]FirewallPolicy, error) + + CreateRuleGroup(ctx context.Context, cfg CreateRuleGroupConfig) (*RuleGroup, error) + DescribeRuleGroup(ctx context.Context, name, arn, ruleType string) (*RuleGroup, error) + DeleteRuleGroup(ctx context.Context, name, arn, ruleType string) (*RuleGroup, error) + ListRuleGroups(ctx context.Context) ([]RuleGroup, error) + + AssociateFirewallPolicy(ctx context.Context, firewallName, policyARN string) (*Firewall, error) + AssociateSubnets(ctx context.Context, firewallName string, subnetIDs []string) (*Firewall, error) + DisassociateSubnets(ctx context.Context, firewallName string, subnetIDs []string) (*Firewall, error) + UpdateFirewallDeleteProtection(ctx context.Context, firewallName string, enabled bool) (*Firewall, error) + UpdateLoggingConfiguration(ctx context.Context, firewallName string, logTypes []string) error + DescribeLoggingConfiguration(ctx context.Context, firewallName string) ([]string, error) + TagResource(ctx context.Context, arn string, tags map[string]string) error + UntagResource(ctx context.Context, arn string, keys []string) error +} diff --git a/services/networking/driver/aws_capabilities.go b/services/networking/driver/aws_capabilities.go new file mode 100644 index 00000000..ec27c1fa --- /dev/null +++ b/services/networking/driver/aws_capabilities.go @@ -0,0 +1,664 @@ +package driver + +import ( + "context" + "time" +) + +// This file defines AWS-specific networking capabilities as OPTIONAL interfaces +// discovered by type assertion, following the VPCAttributes / NetworkInterfaces +// precedent. These resources (Transit Gateways, VPN, DHCP option sets, managed +// prefix lists, egress-only internet gateways, endpoint services, Client VPN) +// don't map cleanly across clouds, so they stay out of the portable Networking +// interface — only the AWS mock implements them, and the EC2 handler serves +// them when the driver satisfies the capability. + +// ---- Transit Gateway ---- + +// TransitGateway is a regional hub that interconnects VPCs and on-prem networks. +type TransitGateway struct { + ID string + State string + ASN int64 + Description string + OwnerID string + Tags map[string]string +} + +// TransitGatewayConfig is the input to CreateTransitGateway. +type TransitGatewayConfig struct { + ASN int64 + Description string + Tags map[string]string +} + +// TransitGatewayVPCAttachment attaches a VPC (via subnets) to a transit gateway. +type TransitGatewayVPCAttachment struct { + ID string + TransitGatewayID string + VPCID string + SubnetIDs []string + State string + Tags map[string]string +} + +// TransitGatewayVPCAttachmentConfig is the input to CreateTransitGatewayVPCAttachment. +type TransitGatewayVPCAttachmentConfig struct { + TransitGatewayID string + VPCID string + SubnetIDs []string + Tags map[string]string +} + +// TransitGatewayRouteTable is a route table owned by a transit gateway. +type TransitGatewayRouteTable struct { + ID string + TransitGatewayID string + State string + Tags map[string]string +} + +// TransitGatewayRoute is a route within a transit gateway route table. +type TransitGatewayRoute struct { + DestinationCIDR string + AttachmentID string + Type string // static | propagated + State string +} + +// TransitGatewayRouteTableAssociation links an attachment to a TGW route table. +type TransitGatewayRouteTableAssociation struct { + RouteTableID string + AttachmentID string + ResourceID string + ResourceType string + State string +} + +// TransitGateways is an OPTIONAL AWS capability (type-asserted). +type TransitGateways interface { + CreateTransitGateway(ctx context.Context, cfg TransitGatewayConfig) (*TransitGateway, error) + DeleteTransitGateway(ctx context.Context, id string) (*TransitGateway, error) + DescribeTransitGateways(ctx context.Context, ids []string) ([]TransitGateway, error) + + CreateTransitGatewayVPCAttachment(ctx context.Context, cfg TransitGatewayVPCAttachmentConfig) (*TransitGatewayVPCAttachment, error) + DeleteTransitGatewayVPCAttachment(ctx context.Context, id string) (*TransitGatewayVPCAttachment, error) + DescribeTransitGatewayVPCAttachments(ctx context.Context, ids []string) ([]TransitGatewayVPCAttachment, error) + + CreateTransitGatewayRouteTable(ctx context.Context, transitGatewayID string, tags map[string]string) (*TransitGatewayRouteTable, error) + DeleteTransitGatewayRouteTable(ctx context.Context, id string) (*TransitGatewayRouteTable, error) + DescribeTransitGatewayRouteTables(ctx context.Context, ids []string) ([]TransitGatewayRouteTable, error) + + CreateTransitGatewayRoute(ctx context.Context, routeTableID, destinationCIDR, attachmentID string) (*TransitGatewayRoute, error) + DeleteTransitGatewayRoute(ctx context.Context, routeTableID, destinationCIDR string) (*TransitGatewayRoute, error) + SearchTransitGatewayRoutes(ctx context.Context, routeTableID string) ([]TransitGatewayRoute, error) + AssociateTransitGatewayRouteTable(ctx context.Context, routeTableID, attachmentID string) (*TransitGatewayRouteTableAssociation, error) + EnableTransitGatewayRouteTablePropagation(ctx context.Context, routeTableID, attachmentID string) error + DisableTransitGatewayRouteTablePropagation(ctx context.Context, routeTableID, attachmentID string) error +} + +// ---- VPN (Customer Gateway / VPN Gateway / VPN Connection) ---- + +// CustomerGateway is the on-prem side of a site-to-site VPN. +type CustomerGateway struct { + ID string + IPAddress string + BGPASN int64 + Type string + State string + Tags map[string]string +} + +// CustomerGatewayConfig is the input to CreateCustomerGateway. +type CustomerGatewayConfig struct { + IPAddress string + BGPASN int64 + Type string + Tags map[string]string +} + +// VPNGateway is the AWS side of a site-to-site VPN (virtual private gateway). +type VPNGateway struct { + ID string + Type string + State string + AmazonSideASN int64 + AttachedVPCID string + AttachmentState string + Tags map[string]string +} + +// VPNGatewayConfig is the input to CreateVPNGateway. +type VPNGatewayConfig struct { + Type string + AmazonSideASN int64 + Tags map[string]string +} + +// VPNConnection is a site-to-site VPN between a VPN gateway and a customer gateway. +type VPNConnection struct { + ID string + CustomerGatewayID string + VPNGatewayID string + TransitGatewayID string + Type string + State string + StaticRoutesOnly bool + Routes []VPNConnectionRoute + Tags map[string]string +} + +// VPNConnectionRoute is a static route on a site-to-site VPN connection. +type VPNConnectionRoute struct { + DestinationCIDR string + State string +} + +// VPNConnectionConfig is the input to CreateVPNConnection. +type VPNConnectionConfig struct { + CustomerGatewayID string + VPNGatewayID string + TransitGatewayID string + Type string + StaticRoutesOnly bool + Tags map[string]string +} + +// VPNConnections is an OPTIONAL AWS capability (type-asserted). +type VPNConnections interface { + CreateCustomerGateway(ctx context.Context, cfg CustomerGatewayConfig) (*CustomerGateway, error) + DeleteCustomerGateway(ctx context.Context, id string) error + DescribeCustomerGateways(ctx context.Context, ids []string) ([]CustomerGateway, error) + + CreateVPNGateway(ctx context.Context, cfg VPNGatewayConfig) (*VPNGateway, error) + DeleteVPNGateway(ctx context.Context, id string) error + DescribeVPNGateways(ctx context.Context, ids []string) ([]VPNGateway, error) + AttachVPNGateway(ctx context.Context, vpnGatewayID, vpcID string) (*VPNGateway, error) + DetachVPNGateway(ctx context.Context, vpnGatewayID, vpcID string) error + + CreateVPNConnection(ctx context.Context, cfg VPNConnectionConfig) (*VPNConnection, error) + DeleteVPNConnection(ctx context.Context, id string) error + DescribeVPNConnections(ctx context.Context, ids []string) ([]VPNConnection, error) + CreateVPNConnectionRoute(ctx context.Context, vpnConnectionID, destinationCIDR string) error + DeleteVPNConnectionRoute(ctx context.Context, vpnConnectionID, destinationCIDR string) error + ModifyVPNConnection(ctx context.Context, id, transitGatewayID, vpnGatewayID string) (*VPNConnection, error) +} + +// ---- DHCP Option Sets ---- + +// DHCPOptions is a set of DHCP options associable with a VPC. +type DHCPOptions struct { + ID string + Configuration map[string][]string // key → values (e.g. "domain-name-servers") + Tags map[string]string +} + +// DHCPOptionsConfig is the input to CreateDHCPOptions. +type DHCPOptionsConfig struct { + Configuration map[string][]string + Tags map[string]string +} + +// DHCPOptionSets is an OPTIONAL AWS capability (type-asserted). +type DHCPOptionSets interface { + CreateDHCPOptions(ctx context.Context, cfg DHCPOptionsConfig) (*DHCPOptions, error) + DeleteDHCPOptions(ctx context.Context, id string) error + DescribeDHCPOptions(ctx context.Context, ids []string) ([]DHCPOptions, error) + AssociateDHCPOptions(ctx context.Context, dhcpOptionsID, vpcID string) error +} + +// ---- Managed Prefix Lists ---- + +// PrefixListEntry is one CIDR entry in a managed prefix list. +type PrefixListEntry struct { + CIDR string + Description string +} + +// PrefixList is a customer-managed collection of CIDR blocks. +type PrefixList struct { + ID string + Name string + AddressFamily string + MaxEntries int + State string + Version int + Entries []PrefixListEntry + Tags map[string]string +} + +// PrefixListConfig is the input to CreateManagedPrefixList. +type PrefixListConfig struct { + Name string + AddressFamily string + MaxEntries int + Entries []PrefixListEntry + Tags map[string]string +} + +// PrefixLists is an OPTIONAL AWS capability (type-asserted). +type PrefixLists interface { + CreateManagedPrefixList(ctx context.Context, cfg PrefixListConfig) (*PrefixList, error) + DeleteManagedPrefixList(ctx context.Context, id string) (*PrefixList, error) + DescribeManagedPrefixLists(ctx context.Context, ids []string) ([]PrefixList, error) + GetManagedPrefixListEntries(ctx context.Context, id string) ([]PrefixListEntry, error) + ModifyManagedPrefixList(ctx context.Context, id string, addEntries []PrefixListEntry, removeCIDRs []string) (*PrefixList, error) +} + +// ---- Egress-only Internet Gateway (IPv6) ---- + +// EgressOnlyInternetGateway provides outbound-only IPv6 for private subnets. +type EgressOnlyInternetGateway struct { + ID string + AttachedVPCID string + State string + Tags map[string]string +} + +// EgressOnlyInternetGateways is an OPTIONAL AWS capability (type-asserted). +type EgressOnlyInternetGateways interface { + CreateEgressOnlyInternetGateway(ctx context.Context, vpcID string, tags map[string]string) (*EgressOnlyInternetGateway, error) + DeleteEgressOnlyInternetGateway(ctx context.Context, id string) error + DescribeEgressOnlyInternetGateways(ctx context.Context, ids []string) ([]EgressOnlyInternetGateway, error) +} + +// ---- VPC Endpoint Services (PrivateLink provider side) ---- + +// EndpointService is a PrivateLink service configuration a provider publishes. +type EndpointService struct { + ID string + ServiceName string + State string + NetworkLoadBalancerARNs []string + AcceptanceRequired bool + AvailabilityZones []string + Tags map[string]string +} + +// EndpointServiceConfig is the input to CreateVPCEndpointServiceConfiguration. +type EndpointServiceConfig struct { + NetworkLoadBalancerARNs []string + AcceptanceRequired bool + Tags map[string]string +} + +// VPCEndpointServices is an OPTIONAL AWS capability (type-asserted). +type VPCEndpointServices interface { + CreateVPCEndpointServiceConfiguration(ctx context.Context, cfg EndpointServiceConfig) (*EndpointService, error) + DeleteVPCEndpointServiceConfiguration(ctx context.Context, id string) error + DescribeVPCEndpointServiceConfigurations(ctx context.Context, ids []string) ([]EndpointService, error) + ModifyVPCEndpointServicePermissions(ctx context.Context, serviceID string, addPrincipals, removePrincipals []string) error + DescribeVPCEndpointServicePermissions(ctx context.Context, serviceID string) ([]string, error) +} + +// ---- Client VPN ---- + +// ClientVPNEndpoint is a managed endpoint remote clients connect to. +type ClientVPNEndpoint struct { + ID string + Description string + ClientCIDRBlock string + ServerCertificateARN string + AuthenticationTypes []string + State string + SplitTunnel bool + VPCID string + Tags map[string]string +} + +// ClientVPNEndpointConfig is the input to CreateClientVPNEndpoint. +type ClientVPNEndpointConfig struct { + Description string + ClientCIDRBlock string + ServerCertificateARN string + AuthenticationTypes []string + SplitTunnel bool + Tags map[string]string +} + +// ClientVPNTargetNetwork associates a subnet with a Client VPN endpoint. +type ClientVPNTargetNetwork struct { + AssociationID string + EndpointID string + SubnetID string + VPCID string + State string +} + +// ClientVPNAuthorizationRule authorizes a client CIDR to reach a target network. +type ClientVPNAuthorizationRule struct { + EndpointID string + TargetCIDR string + GroupID string + AccessAll bool + Status string +} + +// ClientVPNRoute is a route on a Client VPN endpoint. +type ClientVPNRoute struct { + EndpointID string + DestinationCIDR string + TargetSubnetID string + Status string +} + +// ClientVPN is an OPTIONAL AWS capability (type-asserted). +type ClientVPN interface { + CreateClientVPNEndpoint(ctx context.Context, cfg ClientVPNEndpointConfig) (*ClientVPNEndpoint, error) + DeleteClientVPNEndpoint(ctx context.Context, id string) error + DescribeClientVPNEndpoints(ctx context.Context, ids []string) ([]ClientVPNEndpoint, error) + AssociateClientVPNTargetNetwork(ctx context.Context, endpointID, subnetID string) (*ClientVPNTargetNetwork, error) + DisassociateClientVPNTargetNetwork(ctx context.Context, endpointID, associationID string) error + DescribeClientVPNTargetNetworks(ctx context.Context, endpointID string) ([]ClientVPNTargetNetwork, error) + AuthorizeClientVPNIngress(ctx context.Context, endpointID, targetCIDR, groupID string, accessAll bool) (*ClientVPNAuthorizationRule, error) + RevokeClientVPNIngress(ctx context.Context, endpointID, targetCIDR string) error + DescribeClientVPNAuthorizationRules(ctx context.Context, endpointID string) ([]ClientVPNAuthorizationRule, error) + CreateClientVPNRoute(ctx context.Context, endpointID, destinationCIDR, targetSubnetID string) (*ClientVPNRoute, error) + DeleteClientVPNRoute(ctx context.Context, endpointID, destinationCIDR, targetSubnetID string) error + DescribeClientVPNRoutes(ctx context.Context, endpointID string) ([]ClientVPNRoute, error) +} + +// ---- Traffic Mirroring ---- + +// TrafficMirrorTarget is the destination (ENI, NLB, or GWLB endpoint) that +// mirrored packets are copied to. +type TrafficMirrorTarget struct { + ID string + Description string + NetworkInterfaceID string + NetworkLoadBalancerARN string + GatewayLoadBalancerEndpointID string + Type string + OwnerID string + Tags map[string]string +} + +// TrafficMirrorTargetConfig is the input to CreateTrafficMirrorTarget. +type TrafficMirrorTargetConfig struct { + Description string + NetworkInterfaceID string + NetworkLoadBalancerARN string + GatewayLoadBalancerEndpointID string + Tags map[string]string +} + +// TrafficMirrorPortRange is a from/to TCP or UDP port range on a filter rule. +type TrafficMirrorPortRange struct { + FromPort int32 + ToPort int32 +} + +// TrafficMirrorFilterRule is one ingress or egress rule within a filter. +type TrafficMirrorFilterRule struct { + ID string + FilterID string + TrafficDirection string // ingress | egress + RuleNumber int32 + RuleAction string // accept | reject + Protocol int32 + DestinationCIDR string + SourceCIDR string + DestinationPortRange *TrafficMirrorPortRange + SourcePortRange *TrafficMirrorPortRange + Description string +} + +// TrafficMirrorFilterRuleConfig is the input to Create/ModifyTrafficMirrorFilterRule. +type TrafficMirrorFilterRuleConfig struct { + FilterID string + TrafficDirection string + RuleNumber int32 + RuleAction string + Protocol int32 + DestinationCIDR string + SourceCIDR string + DestinationPortRange *TrafficMirrorPortRange + SourcePortRange *TrafficMirrorPortRange + Description string +} + +// TrafficMirrorFilter groups the rules that select which traffic to mirror. +type TrafficMirrorFilter struct { + ID string + Description string + NetworkServices []string + IngressRules []TrafficMirrorFilterRule + EgressRules []TrafficMirrorFilterRule + Tags map[string]string +} + +// TrafficMirrorSession binds a source ENI to a target and filter. +type TrafficMirrorSession struct { + ID string + NetworkInterfaceID string + TrafficMirrorTargetID string + TrafficMirrorFilterID string + PacketLength int32 + SessionNumber int32 + VirtualNetworkID int32 + Description string + OwnerID string + Tags map[string]string +} + +// TrafficMirrorSessionConfig is the input to Create/ModifyTrafficMirrorSession. +type TrafficMirrorSessionConfig struct { + NetworkInterfaceID string + TrafficMirrorTargetID string + TrafficMirrorFilterID string + PacketLength int32 + SessionNumber int32 + VirtualNetworkID int32 + Description string + Tags map[string]string +} + +// TrafficMirroring is an OPTIONAL AWS capability (type-asserted). +type TrafficMirroring interface { + CreateTrafficMirrorTarget(ctx context.Context, cfg TrafficMirrorTargetConfig) (*TrafficMirrorTarget, error) + DeleteTrafficMirrorTarget(ctx context.Context, id string) error + DescribeTrafficMirrorTargets(ctx context.Context, ids []string) ([]TrafficMirrorTarget, error) + + CreateTrafficMirrorFilter(ctx context.Context, description string, tags map[string]string) (*TrafficMirrorFilter, error) + DeleteTrafficMirrorFilter(ctx context.Context, id string) error + DescribeTrafficMirrorFilters(ctx context.Context, ids []string) ([]TrafficMirrorFilter, error) + ModifyTrafficMirrorFilterNetworkServices(ctx context.Context, filterID string, add, remove []string) (*TrafficMirrorFilter, error) + + CreateTrafficMirrorFilterRule(ctx context.Context, cfg TrafficMirrorFilterRuleConfig) (*TrafficMirrorFilterRule, error) + ModifyTrafficMirrorFilterRule( + ctx context.Context, id string, cfg TrafficMirrorFilterRuleConfig, removeFields []string, + ) (*TrafficMirrorFilterRule, error) + DeleteTrafficMirrorFilterRule(ctx context.Context, id string) error + DescribeTrafficMirrorFilterRules(ctx context.Context, filterID string, ruleIDs []string) ([]TrafficMirrorFilterRule, error) + + CreateTrafficMirrorSession(ctx context.Context, cfg TrafficMirrorSessionConfig) (*TrafficMirrorSession, error) + ModifyTrafficMirrorSession( + ctx context.Context, id string, cfg TrafficMirrorSessionConfig, removeFields []string, + ) (*TrafficMirrorSession, error) + DeleteTrafficMirrorSession(ctx context.Context, id string) error + DescribeTrafficMirrorSessions(ctx context.Context, ids []string) ([]TrafficMirrorSession, error) +} + +// ---- Network Insights (Reachability Analyzer & Network Access Analyzer) ---- + +// NetworkInsightsPath describes a source→destination path to analyze for +// reachability. +type NetworkInsightsPath struct { + ID string + ARN string + Protocol string + Source string + SourceARN string + SourceIP string + Destination string + DestinationARN string + DestinationIP string + DestinationPort int32 + CreatedDate time.Time + Tags map[string]string +} + +// NetworkInsightsPathConfig is the input to CreateNetworkInsightsPath. +type NetworkInsightsPathConfig struct { + Protocol string + Source string + Destination string + SourceIP string + DestinationIP string + DestinationPort int32 + Tags map[string]string +} + +// NetworkInsightsAnalysis is the result of running reachability analysis on a +// path. The mock completes analyses synchronously. +type NetworkInsightsAnalysis struct { + ID string + ARN string + PathID string + StartDate time.Time + Status string + StatusMessage string + NetworkPathFound bool + FilterInARNs []string + FilterOutARNs []string + AdditionalAccounts []string + Tags map[string]string +} + +// NetworkInsightsAnalysisConfig is the input to StartNetworkInsightsAnalysis. +type NetworkInsightsAnalysisConfig struct { + PathID string + FilterInARNs []string + FilterOutARNs []string + AdditionalAccounts []string + Tags map[string]string +} + +// AccessScopeResourceStatement selects resources by type and/or id. +type AccessScopeResourceStatement struct { + ResourceTypes []string + Resources []string +} + +// AccessScopeStatement is one end (source or destination) of an access-scope path. +type AccessScopeStatement struct { + ResourceStatement *AccessScopeResourceStatement +} + +// AccessScopePath is one match/exclude path in a Network Access Analyzer scope. +type AccessScopePath struct { + Source *AccessScopeStatement + Destination *AccessScopeStatement +} + +// NetworkInsightsAccessScope is a Network Access Analyzer scope definition. +type NetworkInsightsAccessScope struct { + ID string + ARN string + MatchPaths []AccessScopePath + ExcludePaths []AccessScopePath + CreatedDate time.Time + UpdatedDate time.Time + Tags map[string]string +} + +// NetworkInsightsAccessScopeConfig is the input to CreateNetworkInsightsAccessScope. +type NetworkInsightsAccessScopeConfig struct { + MatchPaths []AccessScopePath + ExcludePaths []AccessScopePath + Tags map[string]string +} + +// NetworkInsightsAccessScopeAnalysis is the result of analyzing an access scope. +type NetworkInsightsAccessScopeAnalysis struct { + ID string + ARN string + AccessScopeID string + Status string + StatusMessage string + StartDate time.Time + EndDate time.Time + FindingsFound string + AnalyzedEniCount int32 + Tags map[string]string +} + +// AccessScopeAnalysisFinding is one finding from an access-scope analysis. +type AccessScopeAnalysisFinding struct { + FindingID string + AnalysisID string + AccessScopeID string +} + +// NetworkInsights is an OPTIONAL AWS capability (type-asserted). It covers both +// Reachability Analyzer (paths + analyses) and Network Access Analyzer (access +// scopes + scope analyses). +type NetworkInsights interface { + CreateNetworkInsightsPath(ctx context.Context, cfg NetworkInsightsPathConfig) (*NetworkInsightsPath, error) + DeleteNetworkInsightsPath(ctx context.Context, id string) error + DescribeNetworkInsightsPaths(ctx context.Context, ids []string) ([]NetworkInsightsPath, error) + + StartNetworkInsightsAnalysis(ctx context.Context, cfg NetworkInsightsAnalysisConfig) (*NetworkInsightsAnalysis, error) + DeleteNetworkInsightsAnalysis(ctx context.Context, id string) error + DescribeNetworkInsightsAnalyses(ctx context.Context, ids []string, pathID string) ([]NetworkInsightsAnalysis, error) + + CreateNetworkInsightsAccessScope(ctx context.Context, cfg NetworkInsightsAccessScopeConfig) (*NetworkInsightsAccessScope, error) + DeleteNetworkInsightsAccessScope(ctx context.Context, id string) error + DescribeNetworkInsightsAccessScopes(ctx context.Context, ids []string) ([]NetworkInsightsAccessScope, error) + GetNetworkInsightsAccessScopeContent(ctx context.Context, id string) (*NetworkInsightsAccessScope, error) + + StartNetworkInsightsAccessScopeAnalysis( + ctx context.Context, accessScopeID string, tags map[string]string, + ) (*NetworkInsightsAccessScopeAnalysis, error) + DeleteNetworkInsightsAccessScopeAnalysis(ctx context.Context, id string) error + DescribeNetworkInsightsAccessScopeAnalyses( + ctx context.Context, ids []string, accessScopeID string, + ) ([]NetworkInsightsAccessScopeAnalysis, error) + GetNetworkInsightsAccessScopeAnalysisFindings(ctx context.Context, analysisID string) ([]AccessScopeAnalysisFinding, string, error) +} + +// ---- VPC Block Public Access ---- + +// VPCBlockPublicAccessOptions is the account/region-level BPA configuration +// singleton. +type VPCBlockPublicAccessOptions struct { + AWSAccountID string + AWSRegion string + State string + InternetGatewayBlockMode string + ExclusionsAllowed string + ManagedBy string + Reason string + LastUpdateTimestamp time.Time +} + +// VPCBlockPublicAccessExclusion exempts a VPC or subnet from the BPA options. +type VPCBlockPublicAccessExclusion struct { + ExclusionID string + InternetGatewayExclusionMode string + ResourceARN string + State string + Reason string + CreationTimestamp time.Time + LastUpdateTimestamp time.Time + Tags map[string]string +} + +// VPCBlockPublicAccessExclusionConfig is the input to CreateVPCBlockPublicAccessExclusion. +type VPCBlockPublicAccessExclusionConfig struct { + VPCID string + SubnetID string + InternetGatewayExclusionMode string + Tags map[string]string +} + +// VPCBlockPublicAccess is an OPTIONAL AWS capability (type-asserted). +type VPCBlockPublicAccess interface { + DescribeVPCBlockPublicAccessOptions(ctx context.Context) (*VPCBlockPublicAccessOptions, error) + ModifyVPCBlockPublicAccessOptions(ctx context.Context, internetGatewayBlockMode string) (*VPCBlockPublicAccessOptions, error) + + CreateVPCBlockPublicAccessExclusion(ctx context.Context, cfg VPCBlockPublicAccessExclusionConfig) (*VPCBlockPublicAccessExclusion, error) + ModifyVPCBlockPublicAccessExclusion(ctx context.Context, id, internetGatewayExclusionMode string) (*VPCBlockPublicAccessExclusion, error) + DeleteVPCBlockPublicAccessExclusion(ctx context.Context, id string) (*VPCBlockPublicAccessExclusion, error) + DescribeVPCBlockPublicAccessExclusions(ctx context.Context, ids []string) ([]VPCBlockPublicAccessExclusion, error) +} diff --git a/services/networking/driver/aws_ipam.go b/services/networking/driver/aws_ipam.go new file mode 100644 index 00000000..1982c1d3 --- /dev/null +++ b/services/networking/driver/aws_ipam.go @@ -0,0 +1,142 @@ +package driver + +import "context" + +// ---- AWS IPAM (IP Address Manager) — OPTIONAL capability (type-asserted) ---- +// +// IPAM is an AWS-only VPC feature exposed on the EC2 query API. Like the other +// AWS networking specifics it is an optional capability discovered via a type +// assertion on the vpc driver, so the portable Networking interface stays clean. + +// Ipam is the top-level IP Address Manager. Creating one implicitly creates a +// public and a private default scope. +type Ipam struct { + ID string + ARN string + Region string + PublicDefaultScopeID string + PrivateDefaultScopeID string + ScopeCount int + DefaultResourceDiscoveryID string + DefaultResourceDiscoveryAssociationID string + ResourceDiscoveryAssociationCount int + OperatingRegions []string + Description string + Tier string + State string + Tags map[string]string +} + +// IpamConfig is the input to CreateIpam. +type IpamConfig struct { + Description string + Tier string + OperatingRegions []string + Tags map[string]string +} + +// IpamScope groups pools. Each IPAM has a public and a private default scope; +// additional private scopes may be created. +type IpamScope struct { + ID string + ARN string + IpamARN string + ScopeType string // public | private + IsDefault bool + PoolCount int + Description string + State string + Tags map[string]string +} + +// IpamScopeConfig is the input to CreateIpamScope. +type IpamScopeConfig struct { + IpamID string + Description string + Tags map[string]string +} + +// IpamPool is a CIDR pool within a scope. +type IpamPool struct { + ID string + ARN string + IpamScopeARN string + IpamScopeType string + AddressFamily string // ipv4 | ipv6 + Locale string + PoolDepth int + Description string + State string + AllocationMinNetmaskLength int + AllocationMaxNetmaskLength int + AllocationDefaultNetmaskLength int + Tags map[string]string +} + +// IpamPoolConfig is the input to CreateIpamPool. +type IpamPoolConfig struct { + IpamScopeID string + AddressFamily string + Locale string + Description string + AllocationMinNetmaskLength int + AllocationMaxNetmaskLength int + AllocationDefaultNetmaskLength int + Tags map[string]string +} + +// IpamPoolCidr is a CIDR provisioned into a pool (the pool's supply). +type IpamPoolCidr struct { + ID string + CIDR string + NetmaskLength int + State string +} + +// IpamPoolAllocation is a CIDR handed out from a pool (the pool's usage). +type IpamPoolAllocation struct { + ID string + CIDR string + ResourceType string + ResourceID string + Description string + Tags map[string]string +} + +// AllocateIpamPoolCidrConfig is the input to AllocateIpamPoolCidr. +type AllocateIpamPoolCidrConfig struct { + IpamPoolID string + CIDR string + NetmaskLength int + Description string + Tags map[string]string +} + +// IPAM is an OPTIONAL AWS capability (type-asserted on the vpc driver). +// +//nolint:interfacebloat // mirrors the IPAM core-lifecycle API surface. +type IPAM interface { + CreateIpam(ctx context.Context, cfg IpamConfig) (*Ipam, error) + DescribeIpams(ctx context.Context, ids []string) ([]Ipam, error) + ModifyIpam(ctx context.Context, id, description string) (*Ipam, error) + DeleteIpam(ctx context.Context, id string) (*Ipam, error) + + CreateIpamScope(ctx context.Context, cfg IpamScopeConfig) (*IpamScope, error) + DescribeIpamScopes(ctx context.Context, ids []string) ([]IpamScope, error) + ModifyIpamScope(ctx context.Context, id, description string) (*IpamScope, error) + DeleteIpamScope(ctx context.Context, id string) (*IpamScope, error) + + CreateIpamPool(ctx context.Context, cfg IpamPoolConfig) (*IpamPool, error) + DescribeIpamPools(ctx context.Context, ids []string) ([]IpamPool, error) + ModifyIpamPool(ctx context.Context, id, description string) (*IpamPool, error) + DeleteIpamPool(ctx context.Context, id string) (*IpamPool, error) + + ProvisionIpamPoolCidr(ctx context.Context, poolID, cidr string, netmaskLength int) (*IpamPoolCidr, error) + DeprovisionIpamPoolCidr(ctx context.Context, poolID, cidr string) (*IpamPoolCidr, error) + GetIpamPoolCidrs(ctx context.Context, poolID string) ([]IpamPoolCidr, error) + + AllocateIpamPoolCidr(ctx context.Context, cfg AllocateIpamPoolCidrConfig) (*IpamPoolAllocation, error) + ReleaseIpamPoolAllocation(ctx context.Context, poolID, allocationID string) error + GetIpamPoolAllocations(ctx context.Context, poolID string) ([]IpamPoolAllocation, error) + ModifyIpamPoolAllocation(ctx context.Context, allocationID, description string) (*IpamPoolAllocation, error) +} diff --git a/services/networking/driver/aws_ipam_byoip.go b/services/networking/driver/aws_ipam_byoip.go new file mode 100644 index 00000000..a3ad07b1 --- /dev/null +++ b/services/networking/driver/aws_ipam_byoip.go @@ -0,0 +1,50 @@ +package driver + +import "context" + +// Byoasn is a bring-your-own Autonomous System Number provisioned into an IPAM. +type Byoasn struct { + Asn string + IpamID string + State string + StatusMessage string +} + +// AsnAssociation links a BYOASN to a BYOIP CIDR. +type AsnAssociation struct { + Asn string + CIDR string + State string + StatusMessage string +} + +// ByoipCidr is a bring-your-own public IP CIDR (optionally moved into IPAM). +type ByoipCidr struct { + CIDR string + Description string + State string + StatusMessage string + NetworkBorderGroup string + AdvertisementType string + AsnAssociations []AsnAssociation +} + +// IPAMByoasn is an OPTIONAL AWS capability for bring-your-own ASN. +type IPAMByoasn interface { + ProvisionIpamByoasn(ctx context.Context, ipamID, asn string) (*Byoasn, error) + DeprovisionIpamByoasn(ctx context.Context, ipamID, asn string) (*Byoasn, error) + DescribeIpamByoasn(ctx context.Context) ([]Byoasn, error) + AssociateIpamByoasn(ctx context.Context, asn, cidr string) (*AsnAssociation, error) + DisassociateIpamByoasn(ctx context.Context, asn, cidr string) (*AsnAssociation, error) +} + +// IPAMByoip is an OPTIONAL AWS capability for bring-your-own public IP CIDRs +// and moving them into IPAM (public-IP insights). +type IPAMByoip interface { + MoveByoipCidrToIpam(ctx context.Context, cidr, ipamPoolID string) (*ByoipCidr, error) + ProvisionByoipCidr(ctx context.Context, cidr, description string) (*ByoipCidr, error) + DeprovisionByoipCidr(ctx context.Context, cidr string) (*ByoipCidr, error) + DescribeByoipCidrs(ctx context.Context) ([]ByoipCidr, error) + AdvertiseByoipCidr(ctx context.Context, cidr string) (*ByoipCidr, error) + WithdrawByoipCidr(ctx context.Context, cidr string) (*ByoipCidr, error) +} diff --git a/services/networking/driver/aws_ipam_discovery.go b/services/networking/driver/aws_ipam_discovery.go new file mode 100644 index 00000000..3030a023 --- /dev/null +++ b/services/networking/driver/aws_ipam_discovery.go @@ -0,0 +1,103 @@ +package driver + +import ( + "context" + "time" +) + +// IpamResourceDiscovery is IPAM's mechanism for finding resources across +// accounts/regions. Each IPAM gets a default one on creation. +type IpamResourceDiscovery struct { + ID string + ARN string + Region string + OwnerID string + OperatingRegions []string + Description string + State string + IsDefault bool + Tags map[string]string +} + +// IpamResourceDiscoveryConfig is the input to CreateIpamResourceDiscovery. +type IpamResourceDiscoveryConfig struct { + Description string + OperatingRegions []string + Tags map[string]string +} + +// IpamResourceDiscoveryAssociation links a resource discovery to an IPAM. +type IpamResourceDiscoveryAssociation struct { + ID string + ARN string + IpamID string + IpamARN string + IpamRegion string + ResourceDiscoveryID string + OwnerID string + State string + IsDefault bool + ResourceDiscoveryStatus string + Tags map[string]string +} + +// IpamDiscoveredAccount is an account IPAM monitors via a resource discovery. +type IpamDiscoveredAccount struct { + AccountID string + DiscoveryRegion string + LastAttemptedDiscoveryTime time.Time + LastSuccessfulDiscoveryTime time.Time +} + +// IpamDiscoveredResourceCidr is a CIDR IPAM discovered on a monitored resource. +type IpamDiscoveredResourceCidr struct { + ResourceDiscoveryID string + ResourceCIDR string + ResourceID string + ResourceType string + ResourceRegion string + ResourceOwnerID string + VPCID string + SubnetID string + AvailabilityZone string + IPSource string + NetworkInterfaceAttachmentStatus string + IPUsage float64 + SampleTime time.Time + Tags map[string]string +} + +// IpamDiscoveredPublicAddress is a public IP IPAM discovered. +type IpamDiscoveredPublicAddress struct { + ResourceDiscoveryID string + Address string + AddressAllocationID string + AddressOwnerID string + AddressRegion string + AddressType string + AssociationStatus string + Service string + VPCID string + SubnetID string + SampleTime time.Time +} + +// IPAMDiscovery is an OPTIONAL AWS capability for IPAM resource discovery. +// +//nolint:interfacebloat // mirrors the IPAM resource-discovery API surface. +type IPAMDiscovery interface { + CreateIpamResourceDiscovery(ctx context.Context, cfg IpamResourceDiscoveryConfig) (*IpamResourceDiscovery, error) + DescribeIpamResourceDiscoveries(ctx context.Context, ids []string) ([]IpamResourceDiscovery, error) + ModifyIpamResourceDiscovery(ctx context.Context, id, description string, operatingRegions []string) (*IpamResourceDiscovery, error) + DeleteIpamResourceDiscovery(ctx context.Context, id string) (*IpamResourceDiscovery, error) + + AssociateIpamResourceDiscovery( + ctx context.Context, ipamID, resourceDiscoveryID string, tags map[string]string, + ) (*IpamResourceDiscoveryAssociation, error) + DisassociateIpamResourceDiscovery(ctx context.Context, associationID string) (*IpamResourceDiscoveryAssociation, error) + DescribeIpamResourceDiscoveryAssociations(ctx context.Context, ids []string) ([]IpamResourceDiscoveryAssociation, error) + + GetIpamDiscoveredAccounts(ctx context.Context, resourceDiscoveryID, region string) ([]IpamDiscoveredAccount, error) + GetIpamDiscoveredResourceCidrs(ctx context.Context, resourceDiscoveryID, region string) ([]IpamDiscoveredResourceCidr, error) + GetIpamDiscoveredPublicAddresses(ctx context.Context, resourceDiscoveryID, region string) ([]IpamDiscoveredPublicAddress, error) +} diff --git a/services/networking/driver/aws_ipam_metrics.go b/services/networking/driver/aws_ipam_metrics.go new file mode 100644 index 00000000..3888cbce --- /dev/null +++ b/services/networking/driver/aws_ipam_metrics.go @@ -0,0 +1,25 @@ +package driver + +import "context" + +// IpamMetricNamespace is the CloudWatch namespace IPAM publishes to. +const IpamMetricNamespace = "AWS/IPAM" + +// IpamMetric is one AWS/IPAM CloudWatch datapoint derived from IPAM state. +// It is deliberately neutral (no dependency on the monitoring driver) so the +// CloudWatch server can adapt it without coupling networking to monitoring. +type IpamMetric struct { + Namespace string + MetricName string + Value float64 + Unit string + Dimensions map[string]string +} + +// IPAMMetrics is an OPTIONAL capability that exposes the AWS/IPAM CloudWatch +// metrics (IPAM/pool/scope/public-IP + resource-utilization) derived from the +// current IPAM and VPC state. The CloudWatch handler surfaces these under the +// AWS/IPAM namespace via ListMetrics/GetMetricData. +type IPAMMetrics interface { + IpamMetrics(ctx context.Context) []IpamMetric +} diff --git a/services/networking/driver/aws_ipam_policy.go b/services/networking/driver/aws_ipam_policy.go new file mode 100644 index 00000000..5e1f8fcf --- /dev/null +++ b/services/networking/driver/aws_ipam_policy.go @@ -0,0 +1,46 @@ +package driver + +import "context" + +// IpamAllocationRule maps a resource type to a source IPAM pool. Mirrors the +// EC2 IpamPolicyAllocationRule, whose only field is the source pool id. +type IpamAllocationRule struct { + SourceIpamPoolID string +} + +// IpamPolicy is an IPAM allocation policy (Organizations-wide governance). +type IpamPolicy struct { + ID string + ARN string + IpamID string + IpamRegion string + OwnerID string + State string + StateMessage string + Enabled bool + // Locale and ResourceType scope the allocation-rule document; set by + // ModifyIpamPolicyAllocationRules and echoed on the policy document. + Locale string + ResourceType string + AllocationRules []IpamAllocationRule + Tags map[string]string +} + +// IPAMPolicy is an OPTIONAL AWS capability for IPAM policies and the +// Organizations delegated-admin account. +// +//nolint:interfacebloat // mirrors the IPAM policy + org-admin API surface. +type IPAMPolicy interface { + CreateIpamPolicy(ctx context.Context, ipamID string, tags map[string]string) (*IpamPolicy, error) + DeleteIpamPolicy(ctx context.Context, id string) (*IpamPolicy, error) + DescribeIpamPolicies(ctx context.Context, ids []string) ([]IpamPolicy, error) + EnableIpamPolicy(ctx context.Context, id, organizationTargetID string) (string, error) + DisableIpamPolicy(ctx context.Context, id string) error + GetEnabledIpamPolicy(ctx context.Context) (policyID string, enabled bool, managedBy string, err error) + ModifyIpamPolicyAllocationRules(ctx context.Context, id, locale, resourceType string, rules []IpamAllocationRule) (*IpamPolicy, error) + GetIpamPolicyAllocationRules(ctx context.Context, id string) (*IpamPolicy, error) + GetIpamPolicyOrganizationTargets(ctx context.Context, id string) ([]string, error) + + EnableIpamOrganizationAdminAccount(ctx context.Context, accountID string) (bool, error) + DisableIpamOrganizationAdminAccount(ctx context.Context, accountID string) (bool, error) +} diff --git a/services/networking/driver/aws_ipam_resolver.go b/services/networking/driver/aws_ipam_resolver.go new file mode 100644 index 00000000..cdcfbf2f --- /dev/null +++ b/services/networking/driver/aws_ipam_resolver.go @@ -0,0 +1,105 @@ +package driver + +import ( + "context" + "time" +) + +// IpamPrefixListResolver auto-syncs IPAM CIDRs into managed prefix lists. +type IpamPrefixListResolver struct { + ID string + ARN string + IpamID string + IpamARN string + IpamRegion string + OwnerID string + AddressFamily string + Description string + State string + LastVersionCreationStatus string + LastVersionCreationStatusMessage string + Tags map[string]string +} + +// IpamPrefixListResolverTarget is a managed prefix list a resolver syncs into. +type IpamPrefixListResolverTarget struct { + ID string + ARN string + ResolverID string + OwnerID string + PrefixListID string + PrefixListRegion string + DesiredVersion int + LastSyncedVersion int + TrackLatestVersion bool + State string + StateMessage string + Tags map[string]string +} + +// IpamPrefixListResolverVersion is a published version of a resolver's rules. +type IpamPrefixListResolverVersion struct { + Version int + CreatedAt time.Time +} + +// IpamPrefixListResolverRule is one rule evaluated by a resolver. +type IpamPrefixListResolverRule struct { + IpamPoolID string + Cidr string +} + +// IpamExternalResourceVerificationToken authorizes external (on-prem) CIDRs. +type IpamExternalResourceVerificationToken struct { + ID string + ARN string + IpamID string + IpamARN string + IpamRegion string + OwnerID string + TokenName string + TokenValue string + NotAfter time.Time + State string + Status string + Tags map[string]string +} + +// IPAMPrefixListResolver is an OPTIONAL AWS capability for IPAM prefix-list +// resolvers, their targets, and published versions. +// +//nolint:interfacebloat // mirrors the prefix-list-resolver API surface. +type IPAMPrefixListResolver interface { + CreateIpamPrefixListResolver( + ctx context.Context, ipamID, addressFamily, description string, tags map[string]string, + ) (*IpamPrefixListResolver, error) + DescribeIpamPrefixListResolvers(ctx context.Context, ids []string) ([]IpamPrefixListResolver, error) + ModifyIpamPrefixListResolver(ctx context.Context, id, description string) (*IpamPrefixListResolver, error) + DeleteIpamPrefixListResolver(ctx context.Context, id string) (*IpamPrefixListResolver, error) + + CreateIpamPrefixListResolverTarget( + ctx context.Context, resolverID, prefixListID, prefixListRegion string, + desiredVersion int, trackLatest bool, tags map[string]string, + ) (*IpamPrefixListResolverTarget, error) + DescribeIpamPrefixListResolverTargets(ctx context.Context, ids []string) ([]IpamPrefixListResolverTarget, error) + ModifyIpamPrefixListResolverTarget( + ctx context.Context, id string, desiredVersion int, trackLatest bool, + ) (*IpamPrefixListResolverTarget, error) + DeleteIpamPrefixListResolverTarget(ctx context.Context, id string) (*IpamPrefixListResolverTarget, error) + + GetIpamPrefixListResolverRules(ctx context.Context, resolverID string) ([]IpamPrefixListResolverRule, error) + GetIpamPrefixListResolverVersions(ctx context.Context, resolverID string) ([]IpamPrefixListResolverVersion, error) + GetIpamPrefixListResolverVersionEntries( + ctx context.Context, resolverID string, version int, + ) ([]PrefixListEntry, error) +} + +// IPAMExternalToken is an OPTIONAL AWS capability for external-resource +// verification tokens. +type IPAMExternalToken interface { + CreateIpamExternalResourceVerificationToken( + ctx context.Context, ipamID, tokenName string, tags map[string]string, + ) (*IpamExternalResourceVerificationToken, error) + DeleteIpamExternalResourceVerificationToken(ctx context.Context, id string) (*IpamExternalResourceVerificationToken, error) + DescribeIpamExternalResourceVerificationTokens(ctx context.Context, ids []string) ([]IpamExternalResourceVerificationToken, error) +} diff --git a/services/networking/driver/aws_ipam_resources.go b/services/networking/driver/aws_ipam_resources.go new file mode 100644 index 00000000..21c37142 --- /dev/null +++ b/services/networking/driver/aws_ipam_resources.go @@ -0,0 +1,50 @@ +package driver + +import ( + "context" + "time" +) + +// IpamResourceCidr is a resource (VPC / subnet / public IPv4 pool) CIDR that +// IPAM tracks within a scope, with its compliance and utilization state. +type IpamResourceCidr struct { + IpamID string + IpamScopeID string + IpamPoolID string + ResourceCIDR string + ResourceID string + ResourceName string + ResourceType string + ResourceRegion string + ResourceOwnerID string + VPCID string + AvailabilityZone string + ComplianceStatus string + ManagementState string + OverlapStatus string + IPUsage float64 + Tags map[string]string +} + +// IpamAddressHistoryRecord is one entry in the history of a CIDR within a scope. +type IpamAddressHistoryRecord struct { + ResourceCIDR string + ResourceID string + ResourceName string + ResourceType string + ResourceRegion string + ResourceOwnerID string + VPCID string + ResourceComplianceStatus string + ResourceOverlapStatus string + SampledStartTime time.Time + SampledEndTime time.Time +} + +// IPAMResources is an OPTIONAL AWS capability exposing IPAM's view of the +// resource CIDRs (VPCs/subnets) it monitors, plus address history. +type IPAMResources interface { + GetIpamResourceCidrs(ctx context.Context, scopeID, resourceID string) ([]IpamResourceCidr, error) + ModifyIpamResourceCidr(ctx context.Context, resourceID, currentScopeID, destScopeID string, monitored bool) (*IpamResourceCidr, error) + GetIpamAddressHistory(ctx context.Context, cidr, scopeID string) ([]IpamAddressHistoryRecord, error) +} diff --git a/services/networking/driver/driver.go b/services/networking/driver/driver.go index 46500639..128baaab 100644 --- a/services/networking/driver/driver.go +++ b/services/networking/driver/driver.go @@ -195,6 +195,10 @@ type InternetGateway struct { // ElasticIPConfig configures an elastic IP allocation. type ElasticIPConfig struct { Tags map[string]string + // SKU (Azure public IP: Basic/Standard) and AllocationMethod (Static/ + // Dynamic) are cost/behaviour inputs a discoverer reads; optional. + SKU string + AllocationMethod string } // ElasticIP represents an elastic IP address. @@ -204,6 +208,10 @@ type ElasticIP struct { AssociationID string InstanceID string Tags map[string]string + // SKU is the Azure public-IP SKU (Basic/Standard), echoed as sku.name. + SKU string + // AllocationMethod is Static/Dynamic (Azure publicIPAllocationMethod). + AllocationMethod string } // RouteTableAssociation represents an association between @@ -379,3 +387,10 @@ type NetworkInterfaces interface { DetachNetworkInterface(ctx context.Context, attachmentID string, force bool) error DeleteNetworkInterface(ctx context.Context, id string) error } + +// NetworkInterfaceCreator is the AWS-specific ENI-creation surface. It's kept +// out of NetworkInterfaces so that adding it doesn't break subset assertions +// (e.g. resourcediscovery's read-only walker, which only needs Describe). +type NetworkInterfaceCreator interface { + CreateNetworkInterface(ctx context.Context, subnetID, description string, tags map[string]string) (*NetworkInterface, error) +} diff --git a/services/networking/networking.go b/services/networking/networking.go index 08141c75..9f79d700 100644 --- a/services/networking/networking.go +++ b/services/networking/networking.go @@ -3,9 +3,9 @@ package networking import ( "context" - cerrors "github.com/stackshy/cloudemu/v2/errors" "time" + cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/features/inject" "github.com/stackshy/cloudemu/v2/features/metrics" "github.com/stackshy/cloudemu/v2/features/ratelimit" diff --git a/services/relationaldb/driver/driver.go b/services/relationaldb/driver/driver.go index b94e5d28..0bee8122 100644 --- a/services/relationaldb/driver/driver.go +++ b/services/relationaldb/driver/driver.go @@ -267,6 +267,12 @@ type DatabaseConfig struct { Name string Charset string Collation string + // SKUName / SKUTier are the database compute SKU (e.g. "GP_Gen5_2" / + // "GeneralPurpose") and ZoneRedundant is the HA flag — cost inputs a + // discoverer reads from an Azure SQL database's sku / properties. + SKUName string + SKUTier string + ZoneRedundant bool } // Database is a logical database hosted by a managed server (Azure MySQL / @@ -277,6 +283,11 @@ type Database struct { Charset string Collation string ARN string + // SKUName / SKUTier / ZoneRedundant are echoed on read for cost discovery + // (Azure SQL database sku.name + properties.currentSku / zoneRedundant). + SKUName string + SKUTier string + ZoneRedundant bool } // Databases is an OPTIONAL capability for managing the logical databases inside @@ -492,7 +503,10 @@ type ManagedInstanceConfig struct { SubnetID string VCores int StorageGB int - Tags map[string]string + // StorageAccountType is the backup storage redundancy (GRS/ZRS/LRS → + // GeoRedundant/ZoneRedundant/LocalRedundant), a per-instance cost input. + StorageAccountType string + Tags map[string]string } // ManagedInstance is a SQL Managed Instance — a fully-managed instance that @@ -507,10 +521,12 @@ type ManagedInstance struct { SubnetID string VCores int StorageGB int - State string - FQDN string - ARN string - Tags map[string]string + // StorageAccountType is the backup storage redundancy echoed on read. + StorageAccountType string + State string + FQDN string + ARN string + Tags map[string]string } // ManagedDatabaseConfig describes a database on a managed instance. diff --git a/services/resourcediscovery/arn.go b/services/resourcediscovery/arn.go index 59cc8e80..b2f80a39 100644 --- a/services/resourcediscovery/arn.go +++ b/services/resourcediscovery/arn.go @@ -36,6 +36,42 @@ func (e *Engine) computeInstanceARN(id string) string { } } +// computeVolumeARN canonicalizes a block-volume id. When the driver already +// hands back a fully-qualified id (an Azure managed-disk ARM path, a GCP +// self-link, or an AWS ARN) it is used verbatim; otherwise a per-provider id +// is built from the short id. +func (e *Engine) computeVolumeARN(id string) string { + if isQualifiedID(id) { + return id + } + + switch e.provider { + case ProviderAWS: + return idgen.AWSARN("ec2", e.region, e.accountID, "volume/"+id) + case ProviderAzure: + return idgen.AzureID(e.accountID, azureDefaultResourceGroup, "Microsoft.Compute", "disks", id) + case ProviderGCP: + return idgen.GCPID(e.accountID, "zones/"+e.region+"/disks", id) + default: + return id + } +} + +// isQualifiedID reports whether id is already a canonical cloud identifier and +// should be used as-is rather than rebuilt. +func isQualifiedID(id string) bool { + switch { + case len(id) >= 4 && id[:4] == "arn:": + return true + case len(id) >= 1 && id[0] == '/': + return true + case len(id) >= 8 && id[:8] == "https://": + return true + default: + return false + } +} + func (e *Engine) networkARN(kind, id string) string { switch e.provider { case ProviderAWS: diff --git a/services/resourcediscovery/engine.go b/services/resourcediscovery/engine.go index 7e435c63..8f810a6f 100644 --- a/services/resourcediscovery/engine.go +++ b/services/resourcediscovery/engine.go @@ -16,14 +16,52 @@ import ( // engine usable in partial test wirings and during the staged rollout of // per-service walkers in later phases. type Drivers struct { - Compute computedriver.Compute - Networking netdriver.Networking - Storage storagedriver.Bucket - Database dbdriver.Database - Serverless serverlessdriver.Serverless - Databricks dbxdriver.Databricks - Kubernetes KubernetesClusters - RelationalDB RelationalDatabases + Compute computedriver.Compute + Networking netdriver.Networking + Storage storagedriver.Bucket + Database dbdriver.Database + Serverless serverlessdriver.Serverless + Databricks dbxdriver.Databricks + Kubernetes KubernetesClusters + RelationalDB RelationalDatabases + ScaleSets ScaleSets + AppServicePlans AppServicePlans +} + +// AppServicePlans is the discovery capability for App Service plans (Azure +// serverfarms) — the resource that carries the SKU/tier an App Service or +// Function App is billed on. Provider-projected, like the other adapters. +type AppServicePlans interface { + DiscoverAppServicePlans(ctx context.Context) ([]DiscoveredAppServicePlan, error) +} + +// DiscoveredAppServicePlan is a provider-neutral projection of an App Service +// plan. Attrs.SKU/SKUTier carry the plan's pricing tier (F1/B1/P1v3/…), the +// primary cost signal for App Service / Functions. +type DiscoveredAppServicePlan struct { + Name string + ARN string + Region string + Tags map[string]string + Attrs Attributes +} + +// ScaleSets is the discovery capability for VM scale sets (Azure VMSS). Like the +// other adapter-projected capabilities, each cloud's mock lives in its provider +// package and wires a thin adapter that projects onto DiscoveredScaleSet. +type ScaleSets interface { + DiscoverScaleSets(ctx context.Context) ([]DiscoveredScaleSet, error) +} + +// DiscoveredScaleSet is a provider-neutral projection of a VM scale set. Attrs +// carries the SKU (name/tier/capacity) and the nested virtualMachineProfile +// properties (priority/licenseType/osType) a discoverer prices on. +type DiscoveredScaleSet struct { + Name string + ARN string + Region string + Tags map[string]string + Attrs Attributes } // RelationalDatabases is the discovery capability for managed relational @@ -48,6 +86,24 @@ type DiscoveredDatabase struct { Region string Type string Tags map[string]string + + // Attrs carries the same generic slots as Resource (SKU/Properties/…) so a + // provider adapter can project a DB's compute SKU, storage, and HA mode + // without a bespoke per-cloud struct. + Attrs Attributes +} + +// Attributes is the generic, resource-agnostic attribute set every Discovered* +// projection can carry, mirroring the slots on Resource. The walker copies it +// onto the emitted Resource verbatim, so no walker branches on resource type. +type Attributes struct { + SKU string + SKUTier string + SKUCapacity int + Kind string + ManagedBy string + Zones []string + Properties map[string]any } // KubernetesClusters is the discovery capability for managed Kubernetes — @@ -77,7 +133,31 @@ type DiscoveredCluster struct { ResourceGroup string ARN string Tags map[string]string - NodeGroups []string + NodeGroups []DiscoveredNodeGroup + + // Attrs carries the generic slots (SKU/Properties/…) for the cluster + // resource, mirroring Resource. + Attrs Attributes +} + +// NodeGroupsFromNames builds name-only node groups (no per-pool attributes), +// for providers that don't yet project per-pool cost signals (EKS/GKE). +func NodeGroupsFromNames(names []string) []DiscoveredNodeGroup { + out := make([]DiscoveredNodeGroup, 0, len(names)) + for _, n := range names { + out = append(out, DiscoveredNodeGroup{Name: n}) + } + + return out +} + +// DiscoveredNodeGroup is a cluster's node-group / node-pool / agent-pool +// projection. Name is surfaced as the NodeGroup resource's id; Attrs carries +// per-pool cost signals (SKU/vmSize, scaleSetPriority for Spot, count, …) that +// a provider adapter fills and the walker copies onto the emitted Resource. +type DiscoveredNodeGroup struct { + Name string + Attrs Attributes } // Engine walks all configured service drivers and returns a normalized @@ -190,5 +270,13 @@ func (e *Engine) walkers() []func(context.Context) ([]Resource, error) { ws = append(ws, e.walkRelationalDB) } + if e.drivers.ScaleSets != nil { + ws = append(ws, e.walkVMSS) + } + + if e.drivers.AppServicePlans != nil { + ws = append(ws, e.walkAppServicePlans) + } + return ws } diff --git a/services/resourcediscovery/kubernetes_walk_test.go b/services/resourcediscovery/kubernetes_walk_test.go index fc6445ee..80da4bb3 100644 --- a/services/resourcediscovery/kubernetes_walk_test.go +++ b/services/resourcediscovery/kubernetes_walk_test.go @@ -31,7 +31,7 @@ func TestWalkKubernetesSurfacesClustersAndNodeGroups(t *testing.T) { Name: "prod", Region: "us-west-2", Tags: map[string]string{"env": "prod"}, - NodeGroups: []string{"ng-a", "ng-b"}, + NodeGroups: NodeGroupsFromNames([]string{"ng-a", "ng-b"}), }, {Name: "bare"}, // no region, no node groups }} diff --git a/services/resourcediscovery/types.go b/services/resourcediscovery/types.go index f6dd5ba1..db9df794 100644 --- a/services/resourcediscovery/types.go +++ b/services/resourcediscovery/types.go @@ -15,6 +15,13 @@ import "time" // Resource is the normalized cross-cloud resource shape. Every walker emits // resources in this form so callers can filter, search, and tag-query // uniformly regardless of provider or service. +// +// The attribute slots below (SKU, Kind, ManagedBy, Zones, Properties) are a +// uniform, resource-agnostic way to carry the type-specific shape a real cloud +// API returns (a VM size, a disk tier + size, a DB compute SKU, …). Every +// walker fills the same slots from its driver's fields, and every row-builder +// renders them the same way, so no layer branches on a specific resource or +// provider type. All slots are optional — an empty slot is omitted downstream. type Resource struct { Provider string Service string @@ -24,6 +31,24 @@ type Resource struct { Region string Tags map[string]string CreatedAt time.Time + + // SKU is the size/tier identifier (VM size, disk tier, DB compute SKU). + SKU string + // SKUTier is the optional SKU tier (e.g. Premium, Standard, Burstable) that + // real cloud APIs carry alongside the SKU name under the `sku` object. + SKUTier string + // SKUCapacity is the optional SKU capacity (e.g. a scale set's instance + // count) that real cloud APIs carry under `sku.capacity`. Zero is omitted. + SKUCapacity int + // Kind is an optional resource sub-kind. + Kind string + // ManagedBy is the id of an owning/parent resource (e.g. a disk's VM). + ManagedBy string + // Zones are the availability zones the resource occupies. + Zones []string + // Properties is an open bag of resource-specific attributes (e.g. disk + // size, OS type, HA mode) keyed by the cloud-native property name. + Properties map[string]any } // Query filters a list operation. All non-empty fields must match. Tags match diff --git a/services/resourcediscovery/walkers.go b/services/resourcediscovery/walkers.go index af67d9a8..51c8d5fb 100644 --- a/services/resourcediscovery/walkers.go +++ b/services/resourcediscovery/walkers.go @@ -6,7 +6,9 @@ import ( cerrors "github.com/stackshy/cloudemu/v2/errors" computedriver "github.com/stackshy/cloudemu/v2/services/compute/driver" + dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" + storagedriver "github.com/stackshy/cloudemu/v2/services/storage/driver" ) // Provider name constants used for routing per-provider ARN construction. @@ -28,25 +30,32 @@ const ( ServiceDatabricks = "databricks" ServiceKubernetes = "kubernetes" ServiceRelationalDB = "relationaldb" + // ServiceAppService buckets App Service plans (Azure serverfarms). They are + // not serverless — they carry a provisioned SKU/tier — so they get their own + // discriminator rather than sharing ServiceServerless with Functions. + ServiceAppService = "appservice" ) // Resource type constants emitted by the walkers. const ( - TypeInstance = "Instance" - TypeVPC = "VPC" - TypeSubnet = "Subnet" - TypeSecurityGroup = "SecurityGroup" - TypeNetworkIface = "NetworkInterface" - TypeElasticIP = "ElasticIP" - TypeBucket = "Bucket" - TypeTable = "Table" - TypeFunction = "Function" - TypeWorkspace = "Workspace" - TypeCluster = "Cluster" - TypeNodeGroup = "NodeGroup" - TypeDBInstance = "DBInstance" - TypeDBCluster = "DBCluster" - TypeDBSnapshot = "DBSnapshot" + TypeInstance = "Instance" + TypeVolume = "Volume" + TypeVPC = "VPC" + TypeSubnet = "Subnet" + TypeSecurityGroup = "SecurityGroup" + TypeNetworkIface = "NetworkInterface" + TypeElasticIP = "ElasticIP" + TypeBucket = "Bucket" + TypeTable = "Table" + TypeFunction = "Function" + TypeWorkspace = "Workspace" + TypeCluster = "Cluster" + TypeNodeGroup = "NodeGroup" + TypeDBInstance = "DBInstance" + TypeDBCluster = "DBCluster" + TypeDBSnapshot = "DBSnapshot" + TypeScaleSet = "ScaleSet" + TypeAppServicePlan = "AppServicePlan" ) // Azure/GCP managed-SQL server types. These portable types map to per-cloud @@ -59,6 +68,7 @@ const ( TypeSQLInstance = "SqlInstance" // GCP Cloud SQL instance TypeManagedInstance = "SqlManagedInstance" // Azure SQL Managed Instance TypeAlloyDBCluster = "AlloyDBCluster" // GCP AlloyDB cluster + TypeSQLDatabase = "SqlDatabase" // Azure SQL logical database ) func (e *Engine) walkCompute(ctx context.Context) ([]Resource, error) { @@ -72,15 +82,80 @@ func (e *Engine) walkCompute(ctx context.Context) ([]Resource, error) { } out := make([]Resource, 0, len(instances)) + for i := range instances { + inst := &instances[i] + + props := map[string]any{} + putStr(props, "priority", inst.Priority) + putStr(props, "licenseType", inst.LicenseType) + // osType nests under storageProfile.osDisk to match the real Azure ARG + // VM shape (a discoverer reads it there). Only Azure VMs set OSType — the + // AWS/GCP compute mocks leave it empty, so no Azure shape leaks onto them. + if inst.OSType != "" { + props["storageProfile"] = map[string]any{"osDisk": map[string]any{"osType": inst.OSType}} + } + out = append(out, Resource{ - Provider: e.provider, - Service: ServiceCompute, - Type: TypeInstance, - ID: instances[i].ID, - ARN: e.computeInstanceARN(instances[i].ID), - Region: e.region, - Tags: copyTags(instances[i].Tags), + Provider: e.provider, + Service: ServiceCompute, + Type: TypeInstance, + ID: inst.ID, + ARN: e.computeInstanceARN(inst.ID), + Region: e.region, + Tags: copyTags(inst.Tags), + SKU: inst.InstanceType, + Zones: cloneStrings(inst.Zones), + Properties: orNilProps(props), + }) + } + + vols, err := e.walkVolumes(ctx) + if err != nil { + return nil, err + } + + return append(out, vols...), nil +} + +// walkVolumes surfaces block volumes (EBS / Azure managed disks / GCE PDs) as +// first-class resources, so a discoverer sees them the way the real cloud APIs +// do. ManagedBy links the volume to its owning instance. +func (e *Engine) walkVolumes(ctx context.Context) ([]Resource, error) { + vols, err := e.drivers.Compute.DescribeVolumes(ctx, nil) + if err != nil { + return nil, fmt.Errorf("walkCompute volumes: %w", err) + } + + out := make([]Resource, 0, len(vols)) + + for i := range vols { + v := &vols[i] + + props := map[string]any{"diskSizeGB": v.Size} + putInt(props, "diskIOPSReadWrite", v.IOPS) + putInt(props, "diskMBpsReadWrite", v.Throughput) + putStr(props, "diskState", v.State) + putStr(props, "tier", v.Tier) + + managedBy := "" + if v.AttachedTo != "" { + managedBy = e.computeInstanceARN(v.AttachedTo) + } + + out = append(out, Resource{ + Provider: e.provider, + Service: ServiceCompute, + Type: TypeVolume, + ID: shortName(v.ID), + ARN: e.computeVolumeARN(v.ID), + Region: e.region, + Tags: copyTags(v.Tags), + SKU: v.VolumeType, + SKUTier: v.Tier, + ManagedBy: managedBy, + Zones: zonesOf(v.AvailabilityZone), + Properties: props, }) } @@ -96,11 +171,17 @@ func (e *Engine) walkNetworking(ctx context.Context) ([]Resource, error) { } for _, v := range vpcs { + var props map[string]any + if v.CIDRBlock != "" { + props = map[string]any{"addressSpace": map[string]any{"addressPrefixes": []string{v.CIDRBlock}}} + } + out = append(out, Resource{ Provider: e.provider, Service: ServiceNetworking, Type: TypeVPC, ID: v.ID, ARN: e.networkARN(netKindVPC, v.ID), Region: e.region, Tags: copyTags(v.Tags), + Properties: props, }) } @@ -110,11 +191,17 @@ func (e *Engine) walkNetworking(ctx context.Context) ([]Resource, error) { } for _, s := range subnets { + var props map[string]any + if s.CIDRBlock != "" { + props = map[string]any{"addressPrefix": s.CIDRBlock} + } + out = append(out, Resource{ Provider: e.provider, Service: ServiceNetworking, Type: TypeSubnet, ID: s.ID, ARN: e.networkARN(netKindSubnet, s.ID), Region: e.region, Tags: copyTags(s.Tags), + Properties: props, }) } @@ -138,11 +225,18 @@ func (e *Engine) walkNetworking(ctx context.Context) ([]Resource, error) { } for _, eip := range eips { + var props map[string]any + if eip.AllocationMethod != "" { + props = map[string]any{"publicIPAllocationMethod": eip.AllocationMethod} + } + out = append(out, Resource{ Provider: e.provider, Service: ServiceNetworking, Type: TypeElasticIP, ID: eip.AllocationID, ARN: e.networkARN(netKindElasticIP, eip.AllocationID), Region: e.region, Tags: copyTags(eip.Tags), + SKU: eip.SKU, + Properties: props, }) } @@ -213,12 +307,33 @@ func (e *Engine) walkStorage(ctx context.Context) ([]Resource, error) { region = e.region } - out = append(out, Resource{ + res := Resource{ Provider: e.provider, Service: ServiceStorage, Type: TypeBucket, ID: b.Name, ARN: e.storageBucketARN(b.Name), Region: region, Tags: tags, - }) + } + + // Optional capability: providers whose buckets carry storage-account + // attributes (Azure) project SKU/kind/access-tier for cost discovery. + // A non-nil error here is load-bearing: silently dropping it would leave + // the cost fields absent — the exact failure this projection closes — so + // propagate it rather than swallow. + if attrer, ok := e.drivers.Storage.(storagedriver.BucketAttributes); ok { + a, aErr := attrer.BucketAttributes(ctx, b.Name) + if aErr != nil { + return nil, fmt.Errorf("walkStorage attributes %q: %w", b.Name, aErr) + } + + res.SKU = a.SKU + res.Kind = a.Kind + + if a.AccessTier != "" { + res.Properties = map[string]any{"accessTier": a.AccessTier} + } + } + + out = append(out, res) } return out, nil @@ -245,12 +360,46 @@ func (e *Engine) walkDatabase(ctx context.Context) ([]Resource, error) { return nil, fmt.Errorf("walkDatabase tags %q: %w", name, tagErr) } - out = append(out, Resource{ + res := Resource{ Provider: e.provider, Service: ServiceDatabase, Type: TypeTable, ID: name, ARN: e.databaseTableARN(name), Region: e.region, Tags: tags, - }) + } + + // Optional capability: providers whose tables map to a richer account + // resource (Azure Cosmos DB) project the account's cost attributes. + // A non-nil error here is load-bearing: silently dropping it would leave + // the cost attributes absent — the exact failure this projection closes — + // so propagate it rather than swallow. + if attrer, ok := e.drivers.Database.(dbdriver.TableAttributes); ok { + a, aErr := attrer.TableAttributes(ctx, name) + if aErr != nil { + return nil, fmt.Errorf("walkDatabase attributes %q: %w", name, aErr) + } + + res.Kind = a.Kind + + props := map[string]any{} + putStr(props, "databaseAccountOfferType", a.OfferType) + + if len(a.Capabilities) > 0 { + caps := make([]any, 0, len(a.Capabilities)) + for _, c := range a.Capabilities { + caps = append(caps, map[string]any{"name": c}) + } + + props["capabilities"] = caps + } + + if a.EnableFreeTier { + props["enableFreeTier"] = true + } + + res.Properties = orNilProps(props) + } + + out = append(out, res) } return out, nil @@ -302,11 +451,20 @@ func (e *Engine) walkDatabricks(ctx context.Context) ([]Resource, error) { region = e.region } + w := &workspaces[i] + + props := map[string]any{} + putStr(props, "workspaceId", w.WorkspaceID) + putStr(props, "provisioningState", w.ProvisioningState) + out = append(out, Resource{ Provider: e.provider, Service: ServiceDatabricks, Type: TypeWorkspace, - ID: workspaces[i].Name, - ARN: workspaces[i].ID, - Region: region, Tags: copyTags(workspaces[i].Tags), + ID: w.Name, + ARN: w.ID, + Region: region, Tags: copyTags(w.Tags), + SKU: w.SKUName, + SKUTier: w.SKUTier, + Properties: orNilProps(props), }) } @@ -339,20 +497,26 @@ func (e *Engine) walkKubernetes(ctx context.Context) ([]Resource, error) { clusterARN = e.kubernetesClusterARN(region, c.ResourceGroup, c.Name) } - out = append(out, Resource{ + cluster := Resource{ Provider: e.provider, Service: ServiceKubernetes, Type: TypeCluster, ID: c.Name, ARN: clusterARN, Region: region, Tags: copyTags(c.Tags), - }) + } + applyAttrs(&cluster, &c.Attrs) + out = append(out, cluster) + + for j := range c.NodeGroups { + ng := &c.NodeGroups[j] - for _, ng := range c.NodeGroups { - out = append(out, Resource{ + pool := Resource{ Provider: e.provider, Service: ServiceKubernetes, Type: TypeNodeGroup, - ID: ng, - ARN: e.kubernetesNodeGroupARN(region, c.ResourceGroup, c.Name, ng), + ID: ng.Name, + ARN: e.kubernetesNodeGroupARN(region, c.ResourceGroup, c.Name, ng.Name), Region: region, - }) + } + applyAttrs(&pool, &ng.Attrs) + out = append(out, pool) } } @@ -385,12 +549,77 @@ func (e *Engine) walkRelationalDB(ctx context.Context) ([]Resource, error) { typ = TypeDBInstance } - out = append(out, Resource{ + r := Resource{ Provider: e.provider, Service: ServiceRelationalDB, Type: typ, ID: d.Name, ARN: d.ARN, Region: region, Tags: copyTags(d.Tags), - }) + } + applyAttrs(&r, &d.Attrs) + out = append(out, r) + } + + return out, nil +} + +// walkVMSS surfaces VM scale sets (Azure VMSS) from the ScaleSets discovery +// adapter, each with its SKU (name/tier/capacity) and nested +// virtualMachineProfile properties. +func (e *Engine) walkVMSS(ctx context.Context) ([]Resource, error) { + sets, err := e.drivers.ScaleSets.DiscoverScaleSets(ctx) + if err != nil { + return nil, fmt.Errorf("walkVMSS: %w", err) + } + + out := make([]Resource, 0, len(sets)) + + for i := range sets { + s := sets[i] + + region := s.Region + if region == "" { + region = e.region + } + + r := Resource{ + Provider: e.provider, Service: ServiceCompute, Type: TypeScaleSet, + ID: s.Name, + ARN: s.ARN, + Region: region, Tags: copyTags(s.Tags), + } + applyAttrs(&r, &s.Attrs) + out = append(out, r) + } + + return out, nil +} + +// walkAppServicePlans surfaces App Service plans (Azure serverfarms) from the +// AppServicePlans discovery adapter, each carrying its pricing tier as sku. +func (e *Engine) walkAppServicePlans(ctx context.Context) ([]Resource, error) { + plans, err := e.drivers.AppServicePlans.DiscoverAppServicePlans(ctx) + if err != nil { + return nil, fmt.Errorf("walkAppServicePlans: %w", err) + } + + out := make([]Resource, 0, len(plans)) + + for i := range plans { + p := plans[i] + + region := p.Region + if region == "" { + region = e.region + } + + r := Resource{ + Provider: e.provider, Service: ServiceAppService, Type: TypeAppServicePlan, + ID: p.Name, + ARN: p.ARN, + Region: region, Tags: copyTags(p.Tags), + } + applyAttrs(&r, &p.Attrs) + out = append(out, r) } return out, nil @@ -408,3 +637,79 @@ func copyTags(src map[string]string) map[string]string { return out } + +// applyAttrs copies the generic attribute slots from a Discovered* projection +// onto the emitted Resource. Kept in one place so every walker fills the slots +// identically, with no per-type branching. +func applyAttrs(r *Resource, a *Attributes) { + r.SKU = a.SKU + r.SKUTier = a.SKUTier + r.SKUCapacity = a.SKUCapacity + r.Kind = a.Kind + r.ManagedBy = a.ManagedBy + r.Zones = cloneStrings(a.Zones) + r.Properties = cloneProps(a.Properties) +} + +func cloneStrings(s []string) []string { + if len(s) == 0 { + return nil + } + + return append([]string(nil), s...) +} + +func cloneProps(src map[string]any) map[string]any { + if len(src) == 0 { + return nil + } + + out := make(map[string]any, len(src)) + for k, v := range src { + out[k] = v + } + + return out +} + +// orNilProps returns nil for an empty map so an empty Properties bag is omitted +// downstream rather than rendered as an empty object. +func orNilProps(m map[string]any) map[string]any { + if len(m) == 0 { + return nil + } + + return m +} + +func putStr(m map[string]any, key, val string) { + if val != "" { + m[key] = val + } +} + +func putInt(m map[string]any, key string, val int) { + if val > 0 { + m[key] = val + } +} + +func zonesOf(zone string) []string { + if zone == "" { + return nil + } + + return []string{zone} +} + +// shortName returns the last path segment of an id (the resource's short name), +// or the id unchanged when it has no separator. +func shortName(id string) string { + for i := len(id) - 1; i >= 0; i-- { + if id[i] == '/' { + return id[i+1:] + } + } + + return id +} diff --git a/services/serverless/driver/driver.go b/services/serverless/driver/driver.go index 60b5d292..06776a79 100644 --- a/services/serverless/driver/driver.go +++ b/services/serverless/driver/driver.go @@ -12,6 +12,15 @@ type FunctionVersion struct { CreatedAt string } +// PermissionStatement is one statement of a function's resource-based policy, +// added via AddPermission (Terraform's aws_lambda_permission). +type PermissionStatement struct { + StatementID string + Action string + Principal string + SourceARN string +} + // AliasConfig configures a function alias. type AliasConfig struct { FunctionName string diff --git a/services/storage/driver/driver.go b/services/storage/driver/driver.go index 6cecbbb1..9ea07bfb 100644 --- a/services/storage/driver/driver.go +++ b/services/storage/driver/driver.go @@ -13,6 +13,23 @@ type BucketInfo struct { CreatedAt string } +// AccountAttributes are the storage-account cost/identity attributes an Azure +// storage account carries but an S3/GCS bucket does not (SKU redundancy, kind, +// access tier). Surfaced through the optional BucketAttributes capability. +type AccountAttributes struct { + SKU string // e.g. Standard_LRS, Premium_LRS + Kind string // e.g. StorageV2, BlobStorage + AccessTier string // Hot / Cool +} + +// BucketAttributes is an OPTIONAL capability, discovered by type assertion (like +// the networking NetworkInterfaces capability): a provider whose buckets map to +// a richer resource (Azure storage accounts) exposes their SKU/kind/access-tier +// for cost discovery. S3/GCS don't implement it and contribute nothing. +type BucketAttributes interface { + BucketAttributes(ctx context.Context, bucket string) (AccountAttributes, error) +} + // ObjectInfo describes a stored object. type ObjectInfo struct { Key string